@rebasepro/types 0.13.0 → 0.13.1-canary.g249daa1

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.
@@ -1,3 +1,4 @@
1
+ import type { DataDriver } from "./controllers/data_driver";
1
2
  import type { StorageSource } from "./controllers/storage";
2
3
  import type { RebaseClient } from "./controllers/client";
3
4
  import type { RebaseSdkData } from "./controllers/data";
@@ -19,7 +20,14 @@ export type RebaseCallContext<USER extends User = User> = {
19
20
  * The Rebase client instance.
20
21
  * Available in all entity callbacks (beforeSave, afterSave, afterRead,
21
22
  * beforeDelete, afterDelete) and in CollectionActionsProps via context.
22
- * Use it to call backend functions, access data, storage, etc.
23
+ * Use it to call backend functions, access storage, send email, etc.
24
+ *
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.
23
31
  *
24
32
  * @example
25
33
  * // In a beforeSave callback:
@@ -35,11 +43,60 @@ export type RebaseCallContext<USER extends User = User> = {
35
43
  * Unified data access — `context.data.products.create(...)`.
36
44
  * Access any collection as a dynamic property.
37
45
  *
38
- * Returns flat rows (`{ id, ...columns }`), identical to the frontend SDK
39
- * client so `context.data` in a backend callback and `client.data` in the
40
- * frontend behave the same way (`row.title`, never `row.values.title`).
46
+ * **Inherits the privilege of whatever triggered the callback.** This is not
47
+ * a fixed trust level, and it is the one thing to know about this accessor:
48
+ *
49
+ * - Triggered by a **user request** (REST, realtime, an admin-panel edit):
50
+ * user-scoped. The callback runs on the RLS-bound transaction opened for
51
+ * that request, so policies apply to reads *and* writes — a callback
52
+ * 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
+ *
56
+ * So a callback that reads a sibling row will find it when an admin task
57
+ * saves and may find nothing when an end user saves — without an error,
58
+ * because RLS filters rather than raises. Write callbacks that tolerate
59
+ * that, or reach for {@link client}`.dataAsAdmin` deliberately when the
60
+ * callback genuinely has to see past its caller.
61
+ *
62
+ * Verified end-to-end against Postgres rather than asserted — see
63
+ * `"scopes context.data to the caller when a callback runs on a user
64
+ * request"` in `server-postgres`' `rls-enforcement` e2e suite. The
65
+ * documentation previously claimed the opposite (that callbacks always have
66
+ * full access), which is the unsafe direction to be wrong in.
67
+ *
68
+ * Returns flat rows (`{ id, ...columns }`), identical in *shape* to the
69
+ * frontend SDK client — so `context.data` in a backend callback and
70
+ * `client.data` in the frontend are accessed the same way (`row.title`,
71
+ * never `row.values.title`). Shape only: privilege differs as above.
41
72
  */
42
73
  data: RebaseSdkData;
74
+ /**
75
+ * The driver executing the operation this callback is attached to.
76
+ *
77
+ * Present server-side only. Declared here because it is already public in
78
+ * practice — the backend has always passed it, and the callbacks guide
79
+ * documented `context.driver.withAuth(user)` in all six locales. The
80
+ * contract simply did not name it, so `buildCallContext` was cast through
81
+ * `as unknown as RebaseCallContext` and nothing about the object was
82
+ * type-checked at all.
83
+ *
84
+ * The guide no longer recommends `withAuth` — {@link data} is already
85
+ * user-scoped on a user request, so the manual re-scoping it described was
86
+ * answering a problem that did not exist. The field stays declared rather
87
+ * than removed: it is on the runtime object, dropping it would break anyone
88
+ * who found it, and a named optional is better than a silent extra.
89
+ *
90
+ * `withAuth` is not on {@link DataDriver} because not every engine supports
91
+ * RLS scoping; it is narrowed here, and left optional so a driver without it
92
+ * is a compile-time absence rather than a runtime surprise.
93
+ */
94
+ driver?: DataDriver & {
95
+ withAuth?(user: {
96
+ uid: string;
97
+ roles?: string[];
98
+ }): Promise<DataDriver>;
99
+ };
43
100
  /**
44
101
  * Used storage implementation
45
102
  */
@@ -371,7 +371,22 @@ export interface RebaseClient<DB = unknown> {
371
371
  setOnUnauthorized?(handler: () => Promise<boolean>): void;
372
372
  /** Resolve the current auth token */
373
373
  resolveToken?(): Promise<string | null>;
374
- /** Make a raw HTTP call to the backend */
374
+ /**
375
+ * POST to an arbitrary path on the backend — the escape hatch, not the way
376
+ * to call a function.
377
+ *
378
+ * For a custom function use {@link functions}`.invoke(name, payload)`: it
379
+ * targets `/functions/<name>`, takes a method and sub-path, and returns the
380
+ * response body as sent. This posts wherever you point it and **unwraps**:
381
+ * it returns `res.data` when the response has a `data` property and the
382
+ * whole envelope otherwise — so an endpoint that legitimately answers
383
+ * `{ data: null }` hands back the envelope rather than `null`. Two ways to
384
+ * reach a function with two different response contracts is a trap; this is
385
+ * the one that exists for paths `invoke` cannot express.
386
+ *
387
+ * @internal Prefer `functions.invoke()`. Kept public because a backend can
388
+ * mount routes outside `/functions`, and nothing else reaches those.
389
+ */
375
390
  call?<T = unknown>(endpoint: string, payload?: unknown): Promise<T>;
376
391
  /**
377
392
  * Execute raw SQL against the database.
@@ -419,64 +434,6 @@ export interface RebaseServerClient<DB = unknown> extends Omit<RebaseClient<DB>,
419
434
  params?: unknown[];
420
435
  }): Promise<Record<string, unknown>[]>;
421
436
  }
422
- /**
423
- * The browser-side Rebase surface — the shape produced by
424
- * `createRebaseClient()` in `@rebasepro/client`.
425
- *
426
- * Its {@link data} accessor is **user-scoped**: every call carries the signed-in
427
- * user's token, so backend RLS policies apply. It deliberately omits the
428
- * server-only members — there is no `sql`, no `email`, and no
429
- * `dataAsAdmin`, so the RLS-bypassing accessor can never be reached from
430
- * browser code.
431
- */
432
- export interface RebaseBrowserClient<DB = unknown> {
433
- /** User-scoped data access layer (carries the signed-in user's token). */
434
- data: RebaseSdkData<DB>;
435
- /** Unified Authentication layer */
436
- auth: AuthClient;
437
- /** Unified Storage layer (default storage source, backward-compatible) */
438
- storage?: StorageSource;
439
- /** Registry of all named storage sources for multi-backend support */
440
- storageRegistry?: StorageSourceRegistry;
441
- /** Build a server-backed {@link StorageSource} for a named storage source. */
442
- createStorageSource?(storageId: string): StorageSource;
443
- /** Discover the storage sources declared on the backend. */
444
- fetchStorageSources?(): Promise<StorageSourceDefinition[]>;
445
- /** Admin API for user management */
446
- admin?: AdminAPI;
447
- /** Cron job management API */
448
- cron?: CronAPI;
449
- /** Database backup management API */
450
- backups?: BackupsAPI;
451
- /** Custom backend functions API */
452
- functions?: FunctionsAPI;
453
- /** Service API keys management API */
454
- apiKeys?: ApiKeysAPI;
455
- /** Base HTTP URL of the backend server */
456
- baseUrl?: string;
457
- /**
458
- * The path every API route is mounted under, appended to {@link baseUrl}.
459
- *
460
- * `"/api"` unless the backend was configured with a different `basePath`
461
- * and the client told to match. Exposed because code that builds a URL by
462
- * hand — rather than going through the client's own methods — otherwise has
463
- * to guess, and guessing `/api` is wrong for exactly the projects that set
464
- * the option.
465
- */
466
- apiPath?: string;
467
- /** WebSocket client for realtime subscriptions */
468
- ws?: RebaseWebSocket;
469
- /** Set the auth token for subsequent requests */
470
- setToken?(token: string | null): void;
471
- /** Set a function that lazily resolves the auth token */
472
- setAuthTokenGetter?(getter: () => Promise<string | null>): void;
473
- /** Set handler called when a request returns 401 */
474
- setOnUnauthorized?(handler: () => Promise<boolean>): void;
475
- /** Resolve the current auth token */
476
- resolveToken?(): Promise<string | null>;
477
- /** Make a raw HTTP call to the backend */
478
- call?<T = unknown>(endpoint: string, payload?: unknown): Promise<T>;
479
- }
480
437
  /**
481
438
  * Client-side registry for managing multiple storage sources.
482
439
  *
@@ -1,3 +1,5 @@
1
+ import type { VectorSearchParams } from "./data_driver";
2
+ import type { ComputedSortField, SearchMatch } from "../types/search";
1
3
  import { Entity, EntityValues } from "../types/entities";
2
4
  import { WhereFilterOp, FieldPath, FilterValues, OrderByTuple } from "../types/filter-operators";
3
5
  export type WhereValue<T> = T | T[] | null;
@@ -34,13 +36,19 @@ export interface FilterCondition {
34
36
  *
35
37
  * `limit`/`offset` and `page` describe the same window two ways. If **both
36
38
  * `offset` and `page` are provided, `page` wins** — the backend computes
37
- * `offset = (page - 1) * (limit ?? 20)` and ignores the explicit `offset`.
38
- * Pick one style per query.
39
+ * `offset = (page - 1) * (limit ?? DEFAULT_LIST_LIMIT)` and ignores the
40
+ * explicit `offset`. Pick one style per query.
39
41
  *
40
42
  * @group Data
41
43
  */
42
44
  export interface FindParams<M extends Record<string, unknown> = Record<string, unknown>> {
43
- /** Maximum number of items to return (default: 20). */
45
+ /**
46
+ * Maximum number of items to return.
47
+ *
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.
51
+ */
44
52
  limit?: number;
45
53
  /**
46
54
  * Number of items to skip. Ignored when {@link FindParams.page} is also
@@ -49,7 +57,7 @@ export interface FindParams<M extends Record<string, unknown> = Record<string, u
49
57
  offset?: number;
50
58
  /**
51
59
  * Page number (1-indexed), alternative to {@link FindParams.offset}.
52
- * When set, overrides `offset` as `(page - 1) * (limit ?? 20)`.
60
+ * When set, overrides `offset` as `(page - 1) * (limit ?? DEFAULT_LIST_LIMIT)`.
53
61
  */
54
62
  page?: number;
55
63
  /**
@@ -76,7 +84,7 @@ export interface FindParams<M extends Record<string, unknown> = Record<string, u
76
84
  * Sort order as a `[field, direction]` tuple.
77
85
  * @example orderBy: ["created_at", "desc"]
78
86
  */
79
- orderBy?: OrderByTuple<FieldPath<M>>;
87
+ orderBy?: OrderByTuple<FieldPath<M> | ComputedSortField>;
80
88
  /**
81
89
  * Relations to include in the response.
82
90
  *
@@ -86,11 +94,45 @@ export interface FindParams<M extends Record<string, unknown> = Record<string, u
86
94
  */
87
95
  include?: string[];
88
96
  /**
89
- * Full-text search string. Matched (OR-ed) across the collection's
90
- * searchable columns, then AND-ed with `where`/`logical`. This is the
91
- * value behind the query builder's `.search()` method.
97
+ * Text search string, AND-ed with `where`/`logical`. This is the value
98
+ * behind the query builder's `.search()` method.
99
+ *
100
+ * What it compiles to depends on the collection. By default — matching
101
+ * every collection that has not said otherwise — it is a case-insensitive
102
+ * substring match OR-ed across the collection's top-level `string`
103
+ * properties: it does not reach inside `map` or `array` properties, it does
104
+ * not stem or rank, and it cannot use an index.
105
+ *
106
+ * A Postgres collection that declares a `search` block instead gets a
107
+ * ranked full-text match over exactly the fields it named, and rows come
108
+ * back with a {@link FindParams.orderBy}-able `_score`.
92
109
  */
93
110
  searchString?: string;
111
+ /**
112
+ * Nearest-neighbour search over a `vector` property.
113
+ *
114
+ * Postgres only, and only for a collection that declares a property of
115
+ * type `vector`. Rows come back ordered by distance, closest first, each
116
+ * carrying a `_distance`. Combines with `where` and `logical`, which are
117
+ * applied as filters before the ordering — so this is "the nearest rows
118
+ * that also match", not "the nearest rows, then filtered".
119
+ *
120
+ * Supplying the query vector is the caller's job: rebase stores and
121
+ * searches embeddings, it does not compute them.
122
+ */
123
+ vectorSearch?: VectorSearchParams;
124
+ /**
125
+ * Ask each returned row to explain itself: which declared search fields
126
+ * matched, with a highlighted snippet from each. Populates `_matches`.
127
+ *
128
+ * Off by default because it is not free — one `ts_headline` per declared
129
+ * field per returned row, and `ts_headline` re-parses the document rather
130
+ * than reading the index. Fine for a page of results, not for an export.
131
+ *
132
+ * Ignored unless the collection declares a `search` block and the query
133
+ * carries a `searchString`; there is nothing to explain otherwise.
134
+ */
135
+ searchExplain?: boolean;
94
136
  }
95
137
  /**
96
138
  * Paginated response from a collection query.
@@ -108,34 +150,49 @@ export interface FindResponse<M extends Record<string, unknown> = Record<string,
108
150
  };
109
151
  }
110
152
  /**
111
- * Fluent query builder for the **admin admin** — resolves to `FindResponse<M>`
153
+ * Fluent query builder for the **admin panel** — resolves to `FindResponse<M>`
112
154
  * (Snapshot-wrapped rows).
113
155
  *
114
156
  * @internal App developers should use {@link SDKQueryBuilderInterface}
115
157
  * (flat rows, returned by `client.data.*` / `context.data.*`). This
116
- * Snapshot-flavored variant backs the admin admin internals only.
158
+ * Snapshot-flavored variant backs the admin panel internals only.
117
159
  *
118
160
  * @group Data
119
161
  */
120
162
  export interface QueryBuilderInterface<M extends Record<string, unknown> = Record<string, unknown>> {
121
163
  where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
122
164
  where(logicalCondition: LogicalCondition): this;
123
- orderBy(column: keyof M & string, direction?: "asc" | "desc"): this;
165
+ orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
124
166
  limit(count: number): this;
125
167
  offset(count: number): this;
126
- search(searchString: string): this;
168
+ search(searchString: string, options?: {
169
+ explain?: boolean;
170
+ }): this;
171
+ /**
172
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
173
+ *
174
+ * Postgres only, over a property declared as `type: "vector"`. Each row
175
+ * comes back with a `_distance`. Any `where` on the same query filters
176
+ * before the ordering; distance decides the order.
177
+ *
178
+ * The query embedding is the caller's to produce.
179
+ */
180
+ vectorSearch(property: string, vector: number[], options?: {
181
+ distance?: "cosine" | "l2" | "inner_product";
182
+ threshold?: number;
183
+ }): this;
127
184
  include(...relations: string[]): this;
128
185
  find(): Promise<FindResponse<M>>;
129
186
  listen(onUpdate: (data: FindResponse<M>) => void, onError?: (error: Error) => void): () => void;
130
187
  }
131
188
  /**
132
- * A single collection's CRUD accessor for the **admin admin** — every method
189
+ * A single collection's CRUD accessor for the **admin panel** — every method
133
190
  * resolves to `Snapshot`-wrapped rows (`FindResponse<M>` / `Snapshot<M>`).
134
191
  *
135
192
  * @internal App developers do **not** use this. The public, symmetric surface
136
193
  * is {@link SDKCollectionClient} (flat rows), exposed as `client.data.products`
137
194
  * in the SDK and `context.data.products` in framework callbacks. This
138
- * Snapshot-flavored accessor backs the admin admin view-model only.
195
+ * Snapshot-flavored accessor backs the admin panel view-model only.
139
196
  *
140
197
  * @group Data
141
198
  */
@@ -169,6 +226,21 @@ export interface CollectionAccessor<M extends Record<string, unknown> = Record<s
169
226
  * @returns The updated entity
170
227
  */
171
228
  update(id: string | number, data: Partial<EntityValues<M>>): Promise<Entity<M>>;
229
+ /**
230
+ * Update many records in a single transaction.
231
+ *
232
+ * See {@link SDKCollectionClient.updateMany}. Optional, as `createMany` is.
233
+ */
234
+ updateMany?(updates: {
235
+ id: string | number;
236
+ data: Partial<EntityValues<M>>;
237
+ }[]): Promise<Entity<M>[]>;
238
+ /**
239
+ * Delete many records in a single transaction.
240
+ *
241
+ * See {@link SDKCollectionClient.deleteMany}. Optional, as `createMany` is.
242
+ */
243
+ deleteMany?(ids: (string | number)[]): Promise<void>;
172
244
  /**
173
245
  * Delete a record by ID.
174
246
  */
@@ -185,14 +257,35 @@ export interface CollectionAccessor<M extends Record<string, unknown> = Record<s
185
257
  listenById?(id: string | number, onUpdate: (entity: Entity<M> | undefined) => void, onError?: (error: Error) => void): () => void;
186
258
  /**
187
259
  * Count the number of records matching the given filter.
260
+ *
261
+ * Optional on this contract because a data source need not support it, and
262
+ * required on `CollectionClient` — the HTTP implementation always has it.
263
+ * So `client.data.posts.count()` compiles in the browser while the same
264
+ * call through a `context.data` accessor needs `count?.()`, which is the
265
+ * one place the two halves of this API are not interchangeable.
188
266
  */
189
267
  count?(params?: FindParams<M>): Promise<number>;
190
268
  where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): QueryBuilderInterface<M>;
191
269
  where(logicalCondition: LogicalCondition): QueryBuilderInterface<M>;
192
- orderBy(column: keyof M & string, direction?: "asc" | "desc"): QueryBuilderInterface<M>;
270
+ orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): QueryBuilderInterface<M>;
193
271
  limit(count: number): QueryBuilderInterface<M>;
194
272
  offset(count: number): QueryBuilderInterface<M>;
195
- search(searchString: string): QueryBuilderInterface<M>;
273
+ search(searchString: string, options?: {
274
+ explain?: boolean;
275
+ }): QueryBuilderInterface<M>;
276
+ /**
277
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
278
+ *
279
+ * Postgres only, over a property declared as `type: "vector"`. Each row
280
+ * comes back with a `_distance`. Any `where` on the same query filters
281
+ * before the ordering; distance decides the order.
282
+ *
283
+ * The query embedding is the caller's to produce.
284
+ */
285
+ vectorSearch(property: string, vector: number[], options?: {
286
+ distance?: "cosine" | "l2" | "inner_product";
287
+ threshold?: number;
288
+ }): QueryBuilderInterface<M>;
196
289
  include(...relations: string[]): QueryBuilderInterface<M>;
197
290
  }
198
291
  /**
@@ -217,11 +310,53 @@ export interface PaginationMeta {
217
310
  * @group Data
218
311
  */
219
312
  export interface FindResult<M extends Record<string, unknown> = Record<string, unknown>> {
220
- /** Array of flat rows matching the query */
221
- data: M[];
313
+ /**
314
+ * Flat rows matching the query, each carrying whatever the query computed
315
+ * for it — see {@link QueryComputedFields}.
316
+ */
317
+ data: (M & QueryComputedFields)[];
222
318
  /** Pagination metadata */
223
319
  meta: PaginationMeta;
224
320
  }
321
+ /**
322
+ * Values a query attaches to a row that are not columns of it.
323
+ *
324
+ * Both are absent unless the query asked for the thing that produces them, so
325
+ * both are optional — and reading one on a query that did not ask returns
326
+ * `undefined` rather than a wrong number.
327
+ *
328
+ * They live here rather than on the row type because a generated row type
329
+ * describes a *table*, and neither of these is in one. Without this, a caller
330
+ * who sorted by relevance could not then read the relevance.
331
+ *
332
+ * A `type` alias, deliberately, not an `interface`. TypeScript grants an
333
+ * implicit index signature to a type alias and withholds it from an interface,
334
+ * so `Row & QueryComputedFields` stops being assignable to
335
+ * `Record<string, unknown>` the moment this becomes an interface. Seven casts
336
+ * in one downstream app broke on exactly that.
337
+ *
338
+ * @group Data
339
+ */
340
+ export type QueryComputedFields = {
341
+ /**
342
+ * Relevance, when the collection declares a {@link SearchConfig} and the
343
+ * query carried a search string. Higher is better; the scale is not
344
+ * comparable between two different search strings.
345
+ */
346
+ _score?: number;
347
+ /**
348
+ * Which declared fields matched, and the text around each hit. Present only
349
+ * when the query asked for it — `.search(term, { explain: true })` — because
350
+ * it costs a `ts_headline` per field per row.
351
+ */
352
+ _matches?: SearchMatch[];
353
+ /**
354
+ * Distance to the query vector, when the query used
355
+ * {@link FindParams.vectorSearch}. Lower is closer, and the rows are
356
+ * already ordered by it.
357
+ */
358
+ _distance?: number;
359
+ };
225
360
  /**
226
361
  * Which column an iteration seeks on, for keyset ("seek") pagination.
227
362
  *
@@ -311,10 +446,25 @@ export type FindAllParams<M extends Record<string, unknown> = Record<string, unk
311
446
  export interface SDKQueryBuilderInterface<M extends Record<string, unknown> = Record<string, unknown>> {
312
447
  where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
313
448
  where(logicalCondition: LogicalCondition): this;
314
- orderBy(column: keyof M & string, direction?: "asc" | "desc"): this;
449
+ orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
315
450
  limit(count: number): this;
316
451
  offset(count: number): this;
317
- search(searchString: string): this;
452
+ search(searchString: string, options?: {
453
+ explain?: boolean;
454
+ }): this;
455
+ /**
456
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
457
+ *
458
+ * Postgres only, over a property declared as `type: "vector"`. Each row
459
+ * comes back with a `_distance`. Any `where` on the same query filters
460
+ * before the ordering; distance decides the order.
461
+ *
462
+ * The query embedding is the caller's to produce.
463
+ */
464
+ vectorSearch(property: string, vector: number[], options?: {
465
+ distance?: "cosine" | "l2" | "inner_product";
466
+ threshold?: number;
467
+ }): this;
318
468
  include(...relations: string[]): this;
319
469
  find(): Promise<FindResult<M>>;
320
470
  count(): Promise<number>;
@@ -461,6 +611,12 @@ export interface SDKCollectionClient<M extends Record<string, unknown> = Record<
461
611
  * Batches are capped server-side (1000 rows by default) because one batch
462
612
  * holds its locks for the whole transaction — chunk larger jobs.
463
613
  *
614
+ * Pass {@link WriteOptions.idempotencyKey} on anything that may be retried.
615
+ * A client that never sees the response cannot know whether the batch
616
+ * committed, and without a key the server cannot tell the retry from a
617
+ * second genuine import — so it performs it again, duplicating every row in
618
+ * the batch rather than just one.
619
+ *
464
620
  * @returns The written rows, in the order given.
465
621
  *
466
622
  * @example
@@ -472,7 +628,7 @@ export interface SDKCollectionClient<M extends Record<string, unknown> = Record<
472
628
  */
473
629
  createMany(data: I[], options?: {
474
630
  upsert?: boolean;
475
- }): Promise<M[]>;
631
+ } & WriteOptions): Promise<M[]>;
476
632
  /**
477
633
  * Update an existing record by ID.
478
634
  * @param data The fields to update (the collection's `Update` shape).
@@ -480,18 +636,94 @@ export interface SDKCollectionClient<M extends Record<string, unknown> = Record<
480
636
  * @throws {RebaseApiError} with status 404 when the record does not exist.
481
637
  */
482
638
  update(id: string | number, data: U): Promise<M>;
639
+ /**
640
+ * Update many records in a single request and a single transaction.
641
+ *
642
+ * The counterpart to {@link createMany}, and the reason it exists is the
643
+ * same: one call per row means one HTTP round trip and one transaction per
644
+ * row. Every record still runs the normal pipeline — callbacks, relations,
645
+ * row-level security — and the batch is all-or-nothing, so a rejected
646
+ * record leaves none of them written and the error names the offending
647
+ * index.
648
+ *
649
+ * Each entry is `{ id, data }` rather than a flat row carrying its own key.
650
+ * That is deliberate: on a table keyed on something other than `id` — a
651
+ * `sku`, a composite key — a flat row cannot say whether a column is the
652
+ * address or a value to write. Naming the address separately mirrors
653
+ * single-row `update(id, data)` exactly and leaves nothing to infer.
654
+ *
655
+ * An id that matches no row fails the batch with a 404 rather than being
656
+ * skipped, for the same reason `update()` does: silently updating four of
657
+ * five rows is worse than updating none.
658
+ *
659
+ * Batches share `createMany`'s server-side cap (1000 rows by default),
660
+ * because one batch holds its locks for the whole transaction.
661
+ *
662
+ * Pass {@link WriteOptions.idempotencyKey} on anything that may be retried.
663
+ * An update replayed in full is naturally idempotent, but one interleaved
664
+ * with another writer's is not — the key is what stops a lost ACK from
665
+ * re-applying a stale batch over newer data.
666
+ *
667
+ * @returns The updated rows, in the order given.
668
+ *
669
+ * @example
670
+ * ```ts
671
+ * await client.data.orders.updateMany([
672
+ * { id: "o-1", data: { status: "shipped" } },
673
+ * { id: "o-2", data: { status: "shipped" } }
674
+ * ]);
675
+ * ```
676
+ */
677
+ updateMany(updates: {
678
+ id: string | number;
679
+ data: U;
680
+ }[], options?: WriteOptions): Promise<M[]>;
483
681
  /**
484
682
  * Delete a record by ID.
485
683
  * @throws {RebaseApiError} with status 404 when the record does not exist.
486
684
  */
487
685
  delete(id: string | number): Promise<void>;
488
686
  /**
489
- * Subscribe to a collection for real-time updates.
687
+ * Delete many records in a single request and a single transaction.
688
+ *
689
+ * Takes ids, not a filter. A filter-shaped bulk delete is a different and
690
+ * far more dangerous operation — the failure mode is an omitted or
691
+ * mistyped condition emptying a table, and it cannot be reviewed at the
692
+ * call site the way an explicit list can. Read first, then pass the ids you
693
+ * meant.
694
+ *
695
+ * `beforeDelete` and `afterDelete` fire per row, exactly as they do for
696
+ * single deletes, and returning `false` from `beforeDelete` fails the batch
697
+ * rather than quietly dropping one row from it. All-or-nothing, so an id
698
+ * that matches no row 404s the whole call.
699
+ *
700
+ * Shares `createMany`'s row cap.
701
+ *
702
+ * @example
703
+ * ```ts
704
+ * const stale = await client.data.sessions.findAll({
705
+ * where: { expires_at: ["<", cutoff] }
706
+ * });
707
+ * await client.data.sessions.deleteMany(stale.map(s => s.id as string));
708
+ * ```
490
709
  */
491
- listen?(params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void): () => void;
710
+ deleteMany(ids: (string | number)[], options?: WriteOptions): Promise<void>;
492
711
  /**
493
- * Subscribe to a single record for real-time updates.
712
+ * The low-level realtime subscription: raw server pushes, nothing else.
713
+ *
714
+ * **Prefer `observe()`** on a client from `@rebasepro/client`, which wraps
715
+ * this one and is what a UI actually wants — it emits from the local
716
+ * database first when offline is enabled, re-emits on local writes and
717
+ * rollbacks, and de-duplicates emissions so a refresh that changes nothing
718
+ * does not call back. `listen` does none of that; it forwards what the
719
+ * socket sends.
720
+ *
721
+ * Optional because it is only present when realtime is enabled. `observe()`
722
+ * is not — it degrades to a single fetch — which is the other reason to
723
+ * reach for it instead.
494
724
  */
725
+ listen?(params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void): () => void;
726
+ /** {@link listen} for a single row. Prefer `observeById()`. */
495
727
  listenById?(id: string | number, onUpdate: (row: M | undefined) => void, onError?: (error: Error) => void): () => void;
496
728
  /**
497
729
  * Count the number of records matching the given filter.
@@ -499,14 +731,25 @@ export interface SDKCollectionClient<M extends Record<string, unknown> = Record<
499
731
  count?(params?: FindParams<M>): Promise<number>;
500
732
  where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): SDKQueryBuilderInterface<M>;
501
733
  where(logicalCondition: LogicalCondition): SDKQueryBuilderInterface<M>;
502
- orderBy(column: keyof M & string, direction?: "asc" | "desc"): SDKQueryBuilderInterface<M>;
734
+ orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): SDKQueryBuilderInterface<M>;
503
735
  limit(count: number): SDKQueryBuilderInterface<M>;
504
736
  offset(count: number): SDKQueryBuilderInterface<M>;
505
- search(searchString: string): SDKQueryBuilderInterface<M>;
737
+ search(searchString: string, options?: {
738
+ explain?: boolean;
739
+ }): SDKQueryBuilderInterface<M>;
740
+ /**
741
+ * Order rows by nearest-neighbour distance to `vector`, closest first.
742
+ * Postgres only, over a `type: "vector"` property. See
743
+ * {@link SDKQueryBuilderInterface.vectorSearch}.
744
+ */
745
+ vectorSearch(property: string, vector: number[], options?: {
746
+ distance?: "cosine" | "l2" | "inner_product";
747
+ threshold?: number;
748
+ }): SDKQueryBuilderInterface<M>;
506
749
  include(...relations: string[]): SDKQueryBuilderInterface<M>;
507
750
  }
508
751
  /**
509
- * The unified data access object for the **admin admin** (Entity-shaped).
752
+ * The unified data access object for the **admin panel** (Entity-shaped).
510
753
  *
511
754
  * Access collections as dynamic properties: `data.products.find(...)`. Each
512
755
  * accessor returns `Entity`-wrapped records (`{ id, path, values }`) — the
@@ -515,7 +758,7 @@ export interface SDKCollectionClient<M extends Record<string, unknown> = Record<
515
758
  *
516
759
  * @internal App developers do **not** use this — they use
517
760
  * {@link RebaseSdkData} (flat rows), which is what the SDK client and backend
518
- * `context.data` expose. This Entity-shaped map backs the admin admin only.
761
+ * `context.data` expose. This Entity-shaped map backs the admin panel only.
519
762
  *
520
763
  * @group Data
521
764
  */
@@ -539,10 +782,15 @@ export type RebaseData<DB = unknown> = {
539
782
  * Dynamic collection accessor.
540
783
  * Access any collection by its slug as a property.
541
784
  *
785
+ * The index signature is `CollectionAccessor` alone, for the reason
786
+ * spelled out on {@link RebaseSdkData}: unioning in the `collection`
787
+ * method's own signature is unnecessary across an intersection, and it
788
+ * costs `data.products.find()` — the access this `@example` documents.
789
+ *
542
790
  * @example
543
791
  * data.products.find({ where: { status: ["==", "published"] } })
544
792
  */
545
- [collectionSlug: string]: CollectionAccessor | ((slug: string) => CollectionAccessor);
793
+ [collectionSlug: string]: CollectionAccessor;
546
794
  });
547
795
  /**
548
796
  * The unified data access object for the **SDK** — flat rows, no Entity wrapper.
@@ -595,6 +843,26 @@ export type InsertOf<T> = T extends {
595
843
  export type UpdateOf<T> = T extends {
596
844
  Update: infer U extends Record<string, unknown>;
597
845
  } ? U : Partial<RowOf<T>>;
846
+ /**
847
+ * Note on the untyped branch below: its index signature is
848
+ * `SDKCollectionClient`, NOT `SDKCollectionClient | ((slug: string) => …)`.
849
+ *
850
+ * The union looks like it is needed so `collection` — a method on this same
851
+ * object — satisfies the index signature. It is not, because `collection` is
852
+ * declared in a *separate* member of the intersection, and TypeScript only
853
+ * requires named properties to be assignable to an index signature declared
854
+ * alongside them. Including the function arm cost the documented accessor:
855
+ *
856
+ * rebase.dataAsAdmin.projects.find()
857
+ * // ^ Property 'find' does not exist on type
858
+ * // 'SDKCollectionClient | ((slug: string) => …)'
859
+ *
860
+ * Every project without a generated `Database` type lands on this branch, so
861
+ * property-style access — the form used by the `@example` below, by the
862
+ * scaffolded function template, and by the 0.13 migration note — did not
863
+ * compile for any of them. Do not restore the arm; use `collection(slug)` if a
864
+ * caller genuinely needs the by-slug function.
865
+ */
598
866
  export type RebaseSdkData<DB = unknown> = {
599
867
  /**
600
868
  * Get a flat collection accessor by slug.
@@ -614,5 +882,5 @@ export type RebaseSdkData<DB = unknown> = {
614
882
  * @example
615
883
  * data.products.find({ where: { status: ["==", "published"] } })
616
884
  */
617
- [collectionSlug: string]: SDKCollectionClient | ((slug: string) => SDKCollectionClient);
885
+ [collectionSlug: string]: SDKCollectionClient;
618
886
  });