@rebasepro/types 0.9.1-canary.fd3754b → 0.10.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.
@@ -86,7 +86,7 @@ export interface AuthAdapterCapabilities {
86
86
  */
87
87
  passwordReset: boolean;
88
88
  /**
89
- * Whether the adapter exposes `POST /admin/users/:userId/reset-password`,
89
+ * Whether the adapter exposes `POST /admin/users/:uid/reset-password`,
90
90
  * letting an admin reset another user's password.
91
91
  *
92
92
  * Independent of `passwordReset`: the built-in adapter supports this even
@@ -180,8 +180,8 @@ export interface UserManagementAdapter {
180
180
  createUser(data: AuthCreateUserData): Promise<AuthUserData>;
181
181
  updateUser(id: string, data: Partial<AuthCreateUserData>): Promise<AuthUserData | null>;
182
182
  deleteUser(id: string): Promise<void>;
183
- getUserRoles(userId: string): Promise<string[]>;
184
- setUserRoles(userId: string, roleIds: string[]): Promise<void>;
183
+ getUserRoles(uid: string): Promise<string[]>;
184
+ setUserRoles(uid: string, roleIds: string[]): Promise<void>;
185
185
  }
186
186
  /**
187
187
  * Result of `AuthAdapter.prepareUserCreation()`.
@@ -201,6 +201,26 @@ export interface UserCreationPrepareResult {
201
201
  /** Whether an invitation was sent (only relevant when hookHandledEmail is true). */
202
202
  invitationSent: boolean;
203
203
  }
204
+ /**
205
+ * What a create body for an auth collection may name beyond the collection's
206
+ * own declared fields — see `AuthAdapter.describeUserCreationContract()`.
207
+ *
208
+ * @group Auth
209
+ */
210
+ export interface UserCreationWriteContract {
211
+ /**
212
+ * Whether to check the body for fields neither the collection nor
213
+ * {@link extraFields} declares. `false` skips the check entirely.
214
+ */
215
+ validate: boolean;
216
+ /**
217
+ * Credential and provider keys the adapter consumes itself, which the
218
+ * collection therefore does not declare as columns. `password` is the
219
+ * canonical one: `prepareUserCreation` hashes it into `passwordHash` and
220
+ * deletes it before the row is ever built.
221
+ */
222
+ extraFields: string[];
223
+ }
204
224
  /**
205
225
  * Result of `AuthAdapter.finalizeUserCreation()`.
206
226
  *
@@ -255,7 +275,7 @@ export interface AuthResponsePayload {
255
275
  */
256
276
  export interface TransformAuthResponseContext {
257
277
  /** The authenticated user's ID. */
258
- userId: string;
278
+ uid: string;
259
279
  /** The auth method that triggered this response. */
260
280
  method: "login" | "register" | "oauth" | "refresh" | "anonymous" | "magic-link" | "mfa";
261
281
  /** The raw HTTP request (for reading headers, IP, etc.). */
@@ -371,6 +391,30 @@ export interface AuthAdapter {
371
391
  * @returns Processed values ready for `driver.save()`, plus metadata for the post-save step.
372
392
  */
373
393
  prepareUserCreation?(values: Record<string, unknown>, collectionAuth?: unknown): Promise<UserCreationPrepareResult>;
394
+ /**
395
+ * Describe what a create body for this auth collection is allowed to name,
396
+ * so unknown-field validation can run on it.
397
+ *
398
+ * A signup body is not the collection's shape: it carries credential fields
399
+ * like `password` that the users table never declares as columns, and
400
+ * `prepareUserCreation` maps them onto real ones. Validating the raw body
401
+ * against the collection alone would reject every legitimate signup — which
402
+ * is why the check used to be skipped outright for auth collections. That
403
+ * skip was total, so an undeclared field was silently dropped and the write
404
+ * still returned 201, while the same typo on a normal collection was a 400.
405
+ *
406
+ * This narrows the exemption to the fields the adapter actually consumes.
407
+ *
408
+ * `validate: false` disables the check for this collection, and is the right
409
+ * answer when a custom `onCreateUser` hook is configured: the body is then
410
+ * the hook's contract, not the collection's, and this layer cannot know what
411
+ * the hook accepts.
412
+ *
413
+ * If not implemented, validation is skipped — the pre-existing behaviour.
414
+ *
415
+ * @param collectionAuth - The parsed `AuthCollectionConfig` from the collection (if `auth` is an object).
416
+ */
417
+ describeUserCreationContract?(collectionAuth?: unknown): UserCreationWriteContract;
374
418
  /**
375
419
  * Finalize a user creation after the entity has been persisted.
376
420
  *
@@ -162,6 +162,40 @@ export interface SingleSubscriptionConfig {
162
162
  path: string;
163
163
  id: string | number;
164
164
  }
165
+ /**
166
+ * Opt-in retention for one set of broadcast channels.
167
+ *
168
+ * Retention is configured on the server and nowhere else. A channel is created
169
+ * by whoever names it, so letting a client ask for its own history depth would
170
+ * let any visitor commit the backend to unbounded storage; and presence-only or
171
+ * notification-only channels — the overwhelming majority — must not pay for a
172
+ * feature they never use. With no rules configured nothing is written, no table
173
+ * is created, and broadcast behaves exactly as it did before history existed.
174
+ */
175
+ export interface ChannelRetentionRule {
176
+ /**
177
+ * Channel name to match. Either exact (`"doc:42"`) or a trailing-`*` prefix
178
+ * (`"doc:*"`). Deliberately not a full glob or RegExp: this decides what
179
+ * gets written to disk, and a rule whose blast radius is not obvious at a
180
+ * glance is the wrong shape for that.
181
+ */
182
+ match: string;
183
+ /** Keep at most this many of the most recent messages per channel. */
184
+ limit?: number;
185
+ /**
186
+ * Keep messages for at most this long. Accepts a millisecond count or a
187
+ * short duration string (`"30s"`, `"15m"`, `"24h"`, `"7d"`).
188
+ */
189
+ ttl?: number | string;
190
+ }
191
+ /** Server-side realtime options. */
192
+ export interface RealtimeChannelsConfig {
193
+ /**
194
+ * Retention rules, most specific first — the first match wins. Omitted or
195
+ * empty means no channel retains anything.
196
+ */
197
+ channels?: ChannelRetentionRule[];
198
+ }
165
199
  /**
166
200
  * Abstract realtime provider interface.
167
201
  * Handles real-time subscriptions and notifications for entity changes.
@@ -258,9 +292,22 @@ export interface SQLAdmin {
258
292
  */
259
293
  fetchAvailableDatabases?(): Promise<string[]>;
260
294
  /**
261
- * Fetch the available database roles.
295
+ * Fetch the available *native PostgreSQL* database roles (from `pg_roles`).
296
+ *
297
+ * These are connection-level roles — what the SQL editor can `SET ROLE` to,
298
+ * and what `SecurityRule.pgRoles` targets. They are NOT application roles;
299
+ * for those use {@link fetchApplicationRoles}.
262
300
  */
263
301
  fetchAvailableRoles?(): Promise<string[]>;
302
+ /**
303
+ * Fetch the *application-level* roles in use in this project.
304
+ *
305
+ * These are the strings stored on the users table's `roles` column and
306
+ * exposed to policies as `auth.roles()` — what `SecurityRule.roles`
307
+ * matches against. Distinct from {@link fetchAvailableRoles}; the two are
308
+ * not interchangeable.
309
+ */
310
+ fetchApplicationRoles?(): Promise<string[]>;
264
311
  /**
265
312
  * Fetch the current database name.
266
313
  */
@@ -330,6 +330,17 @@ export interface BaseCollectionConfig<M extends Record<string, unknown> = Record
330
330
  * This prop has no effect if the history plugin is not enabled
331
331
  */
332
332
  history?: boolean;
333
+ /**
334
+ * Whether a write naming a field this collection does not declare is
335
+ * rejected with a 400. Defaults to `true`.
336
+ *
337
+ * Set to `false` to let unknown keys through to the database, which is what
338
+ * happened before this existed: a typo reached the INSERT and came back as
339
+ * a Postgres error about a column, or — where a column really does exist
340
+ * that the config never declared, populated by a trigger or a default —
341
+ * quietly worked. The second case is the reason for the escape hatch.
342
+ */
343
+ strictWrites?: boolean;
333
344
  /**
334
345
  * Should local changes be backed up in local storage, to prevent data loss on
335
346
  * accidental navigations.
@@ -898,9 +909,14 @@ export interface SecurityRuleBase {
898
909
  * **Shortcut.** Restrict this rule to users that have one of these
899
910
  * application-level roles.
900
911
  *
901
- * **Important:** These are NOT native PostgreSQL database roles. They are
902
- * application roles managed by Rebase, stored in the `rebase.user_roles`
903
- * table, and injected into each transaction via `auth.roles()`.
912
+ * **Important:** These are NOT native PostgreSQL database roles names
913
+ * like `public`, `anon` or `authenticated` belong to {@link pgRoles} and
914
+ * produce a condition no user can satisfy if used here. These are
915
+ * application roles managed by Rebase, stored as an inline `roles TEXT[]`
916
+ * column on the users table, and injected into each transaction as
917
+ * `app.user_roles` — which `auth.roles()` reads.
918
+ *
919
+ * There is no roles registry: a role exists once it is assigned to a user.
904
920
  *
905
921
  * Generates a safe array-overlap condition — the user passes if they hold
906
922
  * *any* of the listed roles:
@@ -929,10 +945,10 @@ export interface SecurityRuleBase {
929
945
  * every database connection). This is correct for most setups where
930
946
  * a single database role is used for all connections.
931
947
  *
932
- * **Important:** These are NOT the same as the application-level `roles`
933
- * (admin, editor, viewer, etc.) — those are enforced in the USING/WITH
934
- * CHECK clauses via `auth.roles()`. This field controls the PostgreSQL
935
- * `TO` clause in `CREATE POLICY ... TO role_name`.
948
+ * **Important:** These are NOT the same as the application-level
949
+ * {@link roles} (admin, editor, viewer, etc.) — those are enforced in the
950
+ * USING/WITH CHECK clauses via `auth.roles()`. This field controls the
951
+ * PostgreSQL `TO` clause in `CREATE POLICY ... TO role_name`.
936
952
  *
937
953
  * Use this if you have dedicated PostgreSQL roles (e.g. `app_read`,
938
954
  * `app_write`) and want policies to target specific ones.
@@ -1147,7 +1163,7 @@ export interface AuthCollectionConfig {
1147
1163
  * Default: generate reset token → send email (or generate + return temp password).
1148
1164
  * Override for custom reset flows.
1149
1165
  */
1150
- onResetPassword?: (userId: string, ctx: AuthCollectionContext) => Promise<AuthCollectionResetResult>;
1166
+ onResetPassword?: (uid: string, ctx: AuthCollectionContext) => Promise<AuthCollectionResetResult>;
1151
1167
  /**
1152
1168
  * Control which auth-specific entity actions are auto-injected.
1153
1169
  *
@@ -74,7 +74,7 @@ export interface AfterReadProps<M extends Record<string, unknown> = Record<strin
74
74
  */
75
75
  path: string;
76
76
  /**
77
- * Fetched row (flat — `{ id, ...columns }`)
77
+ * Fetched row (flat — the table's columns)
78
78
  */
79
79
  row: Record<string, unknown>;
80
80
  /**
@@ -149,7 +149,7 @@ export interface BeforeDeleteProps<M extends Record<string, unknown> = Record<st
149
149
  */
150
150
  id: string | number;
151
151
  /**
152
- * Deleted row (flat — `{ id, ...columns }`)
152
+ * Deleted row (flat — the table's columns)
153
153
  */
154
154
  row: Record<string, unknown>;
155
155
  /**
@@ -175,7 +175,7 @@ export interface AfterDeleteProps<M extends Record<string, unknown> = Record<str
175
175
  */
176
176
  id: string | number;
177
177
  /**
178
- * Deleted row (flat — `{ id, ...columns }`)
178
+ * Deleted row (flat — the table's columns)
179
179
  */
180
180
  row: Record<string, unknown>;
181
181
  /**
@@ -17,7 +17,24 @@
17
17
  *
18
18
  * @group Models
19
19
  */
20
- export type PolicyExpression = TruePolicyExpression | FalsePolicyExpression | AndPolicyExpression | OrPolicyExpression | NotPolicyExpression | ComparePolicyExpression | RolesOverlapPolicyExpression | RolesContainPolicyExpression | AuthenticatedPolicyExpression | ExistsInPolicyExpression | RawPolicyExpression;
20
+ export type PolicyExpression = TruePolicyExpression | FalsePolicyExpression | AndPolicyExpression | OrPolicyExpression | NotPolicyExpression | ComparePolicyExpression | RolesOverlapPolicyExpression | RolesContainPolicyExpression | AuthenticatedPolicyExpression | ServerContextPolicyExpression | ExistsInPolicyExpression | RawPolicyExpression;
21
+ /**
22
+ * The id a request without a logged-in user reports as `auth.uid()`.
23
+ *
24
+ * A user-context request always sets `app.uid`: blank would read back as
25
+ * `NULL`, and `NULL` is how the trusted server context is recognised, so an
26
+ * anonymous visitor would be promoted to server privileges. The driver
27
+ * therefore substitutes this sentinel at the single chokepoint where the GUC
28
+ * is set.
29
+ *
30
+ * The consequence for policy authors is that **`auth.uid() IS NOT NULL` is a
31
+ * tautology on the user path** — it is true for anonymous visitors too. Use
32
+ * {@link policy.authenticated} (or `auth.uid() <> 'anonymous'`) to mean "signed
33
+ * in", and {@link policy.serverContext} to mean "the trusted server context".
34
+ *
35
+ * @group Models
36
+ */
37
+ export declare const ANONYMOUS_USER_ID = "anonymous";
21
38
  /** Always allows. Compiles to `true`. @group Models */
22
39
  export interface TruePolicyExpression {
23
40
  kind: "true";
@@ -72,12 +89,40 @@ export interface RolesContainPolicyExpression {
72
89
  roles: readonly string[];
73
90
  }
74
91
  /**
75
- * True when there is an authenticated user (`auth.uid() IS NOT NULL`).
92
+ * True when a signed-in user is making the request. Compiles to
93
+ * `auth.uid() IS NOT NULL AND auth.uid() <> 'anonymous'`.
94
+ *
95
+ * Both halves are load-bearing. `IS NOT NULL` excludes the server context;
96
+ * the {@link ANONYMOUS_USER_ID} comparison excludes anonymous visitors, who
97
+ * *do* carry a non-null `auth.uid()`. Checking only `IS NOT NULL` grants to
98
+ * everyone — see {@link ANONYMOUS_USER_ID}.
99
+ *
100
+ * `policy.not(policy.authenticated())` therefore means "anonymous visitor or
101
+ * the server context". To single out the server context, use
102
+ * {@link ServerContextPolicyExpression}.
76
103
  * @group Models
77
104
  */
78
105
  export interface AuthenticatedPolicyExpression {
79
106
  kind: "authenticated";
80
107
  }
108
+ /**
109
+ * True only in the trusted **server context** — the built-in flows that run
110
+ * without a user (signup, migrations, `dataAsAdmin`) set no user GUC, so
111
+ * `auth.uid()` is `NULL` for them and only for them. Compiles to
112
+ * `auth.uid() IS NULL`.
113
+ *
114
+ * This is what lets the owner connection satisfy a policy even under FORCE RLS.
115
+ * It is deliberately a primitive rather than `not(authenticated())`: the two
116
+ * meant the same thing while `authenticated` ignored {@link ANONYMOUS_USER_ID},
117
+ * and conflating them is what turns a server-only grant into an anonymous one.
118
+ *
119
+ * The JavaScript evaluator always returns `false` for this node — a client is
120
+ * never the server context.
121
+ * @group Models
122
+ */
123
+ export interface ServerContextPolicyExpression {
124
+ kind: "serverContext";
125
+ }
81
126
  /**
82
127
  * Membership / relational access: true when at least one row exists in another
83
128
  * collection (a join/membership table) matching `where`. This is what lets you
@@ -178,6 +223,7 @@ export declare const policy: {
178
223
  rolesOverlap: (roles: readonly string[]) => RolesOverlapPolicyExpression;
179
224
  rolesContain: (roles: readonly string[]) => RolesContainPolicyExpression;
180
225
  authenticated: () => AuthenticatedPolicyExpression;
226
+ serverContext: () => ServerContextPolicyExpression;
181
227
  existsIn: (args: {
182
228
  collection: string;
183
229
  where: PolicyExpression;
@@ -175,6 +175,19 @@ export interface BaseProperty<CustomProps = unknown> {
175
175
  * Rules for validating this property
176
176
  */
177
177
  validation?: PropertyValidationSchema;
178
+ /**
179
+ * Never include this column in an API response.
180
+ *
181
+ * For secrets the server must store and read but no client should ever
182
+ * receive — password hashes, verification tokens. The value is still
183
+ * written and queryable server-side; it is stripped from every row the API
184
+ * serves, for every caller, including admins and service keys.
185
+ *
186
+ * This is a server-side guarantee, unlike `ui.hideFromCollection`, which
187
+ * only stops the admin panel from *rendering* a field and leaves it in the
188
+ * JSON payload.
189
+ */
190
+ excludeFromApi?: boolean;
178
191
  /**
179
192
  * Use this to define dynamic properties that change based on certain conditions
180
193
  * or on the entity's values. For example, you can make a field read-only if
@@ -14,11 +14,42 @@ export interface WebSocketMessage {
14
14
  rows?: Record<string, unknown>[];
15
15
  row?: Record<string, unknown> | null;
16
16
  error?: string;
17
+ /**
18
+ * Channel name, on broadcast and presence frames.
19
+ *
20
+ * These are addressed by channel rather than by `requestId` or
21
+ * `subscriptionId`, so this is the only field that routes them.
22
+ */
23
+ channel?: string;
17
24
  }
25
+ /**
26
+ * The key columns a collection's rows are addressed by.
27
+ *
28
+ * A row is exactly its columns and carries no address, so a subscriber that has
29
+ * to recognise one — to patch it, or to keep its reference across a refetch —
30
+ * derives the address from these. The SDK is usable with no collections
31
+ * declared at all, so the server is the only side that knows them.
32
+ *
33
+ * Undefined when the server cannot resolve them: a table with no primary key
34
+ * and no `id` column has no address, and rows of it cannot be recognised by
35
+ * anyone.
36
+ */
37
+ export type WirePrimaryKeys = {
38
+ fieldName: string;
39
+ type: "string" | "number";
40
+ isUUID?: boolean;
41
+ }[];
18
42
  export interface CollectionUpdateMessage extends WebSocketMessage {
19
43
  type: "collection_update";
20
44
  subscriptionId: string;
21
45
  rows: Record<string, unknown>[];
46
+ /**
47
+ * See {@link WirePrimaryKeys}. Sent with the rows themselves — and not only
48
+ * with a patch — because a CDC-originated change sends no patch at all: it
49
+ * invalidates and goes straight to a refetch, and the merge that preserves
50
+ * unchanged rows' references needs an address to match them by.
51
+ */
52
+ pks?: WirePrimaryKeys;
22
53
  }
23
54
  export interface SingleUpdateMessage extends WebSocketMessage {
24
55
  type: "single_update";
@@ -34,9 +65,55 @@ export interface SingleUpdateMessage extends WebSocketMessage {
34
65
  export interface CollectionPatchMessage extends WebSocketMessage {
35
66
  type: "collection_patch";
36
67
  subscriptionId: string;
68
+ /** The address of the row this patch refers to — derived, never read off it. */
37
69
  id: string;
38
70
  /** The updated row, or null if deleted */
39
71
  row: Record<string, unknown> | null;
72
+ /** See {@link WirePrimaryKeys}: how the subscriber finds {@link id} in its cache. */
73
+ pks?: WirePrimaryKeys;
74
+ }
75
+ /**
76
+ * One retained broadcast, as it travels on the wire.
77
+ *
78
+ * `seq` is per-channel, dense and monotonically increasing — it is the only
79
+ * thing a reconnecting client needs to say where it got to. See
80
+ * {@link ChannelHistoryMessage}.
81
+ */
82
+ export interface ChannelHistoryEntry {
83
+ seq: number;
84
+ event: string;
85
+ payload: unknown;
86
+ /**
87
+ * The server-side client id of whoever sent it, for information only.
88
+ *
89
+ * Deliberately not used to filter a client's own messages out of a replay:
90
+ * a reconnect assigns a brand-new client id, so the very case replay exists
91
+ * for is the case where this would fail to match. Consumers that cannot
92
+ * tolerate re-applying their own operations must make them idempotent.
93
+ */
94
+ senderId?: string;
95
+ /** When the server accepted it, ISO-8601. */
96
+ at: string;
97
+ }
98
+ /**
99
+ * Server → client: the retained messages a client missed.
100
+ *
101
+ * `retained: false` means the channel has no retention rule configured, so
102
+ * there is no history to replay and there never will be — an explicit answer
103
+ * rather than an empty one, so a client can tell "nothing missed" apart from
104
+ * "this channel does not keep history".
105
+ */
106
+ export interface ChannelHistoryMessage extends WebSocketMessage {
107
+ type: "channel_history";
108
+ channel: string;
109
+ messages: ChannelHistoryEntry[];
110
+ retained: boolean;
111
+ /**
112
+ * The highest seq the server holds for this channel, whether or not it was
113
+ * returned. Lets a client that capped its request with `limit` see that it
114
+ * is still behind, and decide to resync wholesale instead of paging.
115
+ */
116
+ latestSeq?: number;
40
117
  }
41
118
  /**
42
119
  * Column metadata returned by table introspection.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/types",
3
3
  "type": "module",
4
- "version": "0.9.1-canary.fd3754b",
4
+ "version": "0.10.0",
5
5
  "description": "Rebase type definitions — shared interfaces and controller types",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -13,12 +13,12 @@
13
13
  "url": "https://github.com/rebasepro/rebase.git",
14
14
  "directory": "packages/types"
15
15
  },
16
- "main": "./dist/index.umd.js",
16
+ "main": "./dist/index.es.js",
17
17
  "module": "./dist/index.es.js",
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=14"
21
+ "node": ">=20"
22
22
  },
23
23
  "keywords": [
24
24
  "rebase",
@@ -33,8 +33,7 @@
33
33
  ".": {
34
34
  "types": "./dist/index.d.ts",
35
35
  "development": "./dist/index.es.js",
36
- "import": "./dist/index.es.js",
37
- "require": "./dist/index.umd.js"
36
+ "import": "./dist/index.es.js"
38
37
  },
39
38
  "./package.json": "./package.json"
40
39
  },
@@ -92,7 +91,7 @@
92
91
  },
93
92
  "scripts": {
94
93
  "watch": "vite build --watch",
95
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
94
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
96
95
  "test:lint": "eslint \"src/**\" --quiet",
97
96
  "test": "jest --passWithNoTests",
98
97
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
@@ -139,11 +139,11 @@ export interface AdminAPI {
139
139
  orderBy?: string;
140
140
  orderDir?: "asc" | "desc";
141
141
  }): Promise<{ users: AdminUser[]; total: number; limit: number; offset: number }>;
142
- getUser(userId: string): Promise<{ user: AdminUser }>;
142
+ getUser(uid: string): Promise<{ user: AdminUser }>;
143
143
  createUser(data: { email: string; displayName?: string; password?: string; roles?: string[]; metadata?: Record<string, any> }): Promise<{ user: AdminUser }>;
144
- updateUser(userId: string, data: { email?: string; displayName?: string; password?: string; roles?: string[]; metadata?: Record<string, any> }): Promise<{ user: AdminUser }>;
145
- deleteUser(userId: string): Promise<{ success: boolean }>;
146
- resetPassword(userId: string, options?: { password?: string }): Promise<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean; emailDeliveryFailed?: boolean }>;
144
+ updateUser(uid: string, data: { email?: string; displayName?: string; password?: string; roles?: string[]; metadata?: Record<string, any> }): Promise<{ user: AdminUser }>;
145
+ deleteUser(uid: string): Promise<{ success: boolean }>;
146
+ resetPassword(uid: string, options?: { password?: string }): Promise<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean; emailDeliveryFailed?: boolean }>;
147
147
  listRoles(): Promise<{ roles: Array<{ id: string; name: string }> }>;
148
148
  bootstrap(): Promise<{ success: boolean; message: string; user: { uid: string; roles: string[] } }>;
149
149
  }
@@ -161,6 +161,14 @@ export interface CollectionAccessor<M extends Record<string, unknown> = Record<s
161
161
  */
162
162
  create(data: Partial<EntityValues<M>>, id?: string | number): Promise<Entity<M>>;
163
163
 
164
+ /**
165
+ * Create many records in a single transaction.
166
+ *
167
+ * See {@link SDKCollectionClient.createMany}. Optional: not every driver can
168
+ * write in bulk, and callers should fall back to `create` per record.
169
+ */
170
+ createMany?(data: Partial<EntityValues<M>>[], options?: { upsert?: boolean }): Promise<Entity<M>[]>;
171
+
164
172
  /**
165
173
  * Update an existing record by ID.
166
174
  * @returns The updated entity
@@ -294,11 +302,43 @@ export interface SDKCollectionClient<
294
302
  /**
295
303
  * Create a new record.
296
304
  * @param data The record data to create (the collection's `Insert` shape).
297
- * @param id Optional specific ID to use for the new record.
305
+ * @param id Optional specific id, sent as an `id` column. This is for tables
306
+ * whose key *is* `id`: the value goes in as that column. For a table keyed
307
+ * on anything else (a `sku`, a composite key), there is no `id` column to
308
+ * receive it — put the key in `data` instead, where it belongs among the
309
+ * columns.
298
310
  * @returns The created row
299
311
  */
300
312
  create(data: I, id?: string | number): Promise<M>;
301
313
 
314
+ /**
315
+ * Write many records in a single request and a single transaction.
316
+ *
317
+ * Built for imports and ETL, where one call per row means one HTTP round
318
+ * trip and one transaction per row. Every record still runs the normal
319
+ * pipeline — callbacks, relations, row-level security — and the batch is
320
+ * all-or-nothing: if any record is rejected, none of them land and the
321
+ * error names the offending index.
322
+ *
323
+ * A record carrying its primary key updates that row; one without inserts.
324
+ * With `{ upsert: true }` each record is written as INSERT ... ON CONFLICT
325
+ * DO UPDATE on the primary key instead, which is what makes a re-runnable
326
+ * import idempotent.
327
+ *
328
+ * Batches are capped server-side (1000 rows by default) because one batch
329
+ * holds its locks for the whole transaction — chunk larger jobs.
330
+ *
331
+ * @returns The written rows, in the order given.
332
+ *
333
+ * @example
334
+ * ```ts
335
+ * for (const chunk of chunks(rows, 1000)) {
336
+ * await client.data.products.createMany(chunk, { upsert: true });
337
+ * }
338
+ * ```
339
+ */
340
+ createMany(data: I[], options?: { upsert?: boolean }): Promise<M[]>;
341
+
302
342
  /**
303
343
  * Update an existing record by ID.
304
344
  * @param data The fields to update (the collection's `Update` shape).
@@ -386,7 +426,7 @@ export type RebaseData<DB = unknown> = {
386
426
  * - The frontend SDK client (`client.data.products.find()`)
387
427
  * - Backend framework callbacks & scripts (`context.data.products.find()`)
388
428
  *
389
- * Every accessor returns flat rows (`{ id, ...columns }`) via
429
+ * Every accessor returns flat rows (the table's columns) via
390
430
  * {@link SDKCollectionClient} — access fields directly (`row.title`), never
391
431
  * `row.values.title`. The admin CMS uses {@link RebaseData} (Entity) instead.
392
432
  *
@@ -77,6 +77,31 @@ export interface SaveProps<M extends Record<string, unknown> = Record<string, un
77
77
  previousValues?: Partial<EntityValues<M>>;
78
78
  collection?: CollectionConfig<M>;
79
79
  status: EntityStatus;
80
+ /**
81
+ * Write the row with INSERT ... ON CONFLICT DO UPDATE on the primary key
82
+ * instead of choosing between insert and update up front.
83
+ *
84
+ * One statement, so it does not lose the race a read-then-write can, and it
85
+ * succeeds whether or not the row is already there — what a re-runnable
86
+ * import needs. Requires every primary key column to be present; without
87
+ * them there is no conflict target and the row is inserted normally.
88
+ */
89
+ upsert?: boolean;
90
+ }
91
+
92
+ /**
93
+ * @internal
94
+ */
95
+ export interface SaveManyProps<M extends Record<string, unknown> = Record<string, unknown>> {
96
+ path: string;
97
+ /**
98
+ * The rows to write. A row carrying its primary key updates (or, with
99
+ * `upsert`, inserts-or-updates) that row; one without inserts.
100
+ */
101
+ rows: Partial<EntityValues<M>>[];
102
+ collection?: CollectionConfig<M>;
103
+ /** Apply every row as INSERT ... ON CONFLICT DO UPDATE. See {@link SaveProps.upsert}. */
104
+ upsert?: boolean;
80
105
  }
81
106
 
82
107
  /**
@@ -154,6 +179,20 @@ export interface DataDriver {
154
179
  */
155
180
  save<M extends Record<string, unknown> = Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>>;
156
181
 
182
+ /**
183
+ * Save many rows as one unit of work.
184
+ *
185
+ * Every row runs the same pipeline as {@link save} — callbacks, relations
186
+ * and row-level security all still apply — but they share a single
187
+ * transaction, so the batch either lands whole or not at all. That, and the
188
+ * single round trip, is what makes importing tens of thousands of rows
189
+ * viable without dropping to raw SQL.
190
+ *
191
+ * Optional: drivers that cannot do this leave it undefined and callers fall
192
+ * back to `save` per row.
193
+ */
194
+ saveMany?<M extends Record<string, unknown> = Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]>;
195
+
157
196
  /**
158
197
  * Delete a entity
159
198
  * @param props
@@ -252,9 +291,14 @@ export interface DataDriver {
252
291
  * REST-optimised fetch service exposed by drivers that support
253
292
  * eager-loading of relations via `include`.
254
293
  *
255
- * The methods return flattened rows (`{ id, ...columns }`) with
256
- * included relations inlined as plain nested rowsthe shape served
257
- * to app developers through the REST API / SDK client.
294
+ * The methods return flattened rows exactly the table's columns, under their
295
+ * own names and with the types the database returned and included relations
296
+ * inlined as plain nested rows. This is the shape served to app developers
297
+ * through the REST API / SDK client.
298
+ *
299
+ * No synthesized `id`: identity is a primary key, which may be named anything
300
+ * and span several columns, so an address is derived by whoever needs one (see
301
+ * `buildCompositeId`) rather than written into the row on top of the data.
258
302
  *
259
303
  * @group DataDriver
260
304
  */