@rebasepro/client 0.6.1 → 0.8.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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Client-side storage source registry.
3
+ *
4
+ * Manages multiple `StorageSource` instances keyed by
5
+ * `StorageSourceDefinition.key`. Collection properties reference
6
+ * a source by key via `StorageConfig.storageSource`.
7
+ *
8
+ * Typical bootstrap flow:
9
+ * 1. Fetch definitions from `GET /api/storage/sources`
10
+ * 2. Build server-backed sources automatically via `createStorage(transport, key)`
11
+ * 3. Register "direct" sources manually (e.g. Firebase Storage hook)
12
+ */
13
+ import type { StorageSource, StorageSourceRegistry, StorageSourceDefinition } from "@rebasepro/types";
14
+ import type { Transport } from "./transport";
15
+ /**
16
+ * Default implementation of the client-side `StorageSourceRegistry`.
17
+ */
18
+ export declare class ClientStorageSourceRegistry implements StorageSourceRegistry {
19
+ private sources;
20
+ /**
21
+ * Register a storage source.
22
+ * @param key - Unique key matching a `StorageSourceDefinition.key`
23
+ * @param source - The `StorageSource` instance
24
+ */
25
+ register(key: string, source: StorageSource): void;
26
+ getDefault(): StorageSource;
27
+ get(key: string | undefined | null): StorageSource | undefined;
28
+ getOrDefault(key: string | undefined | null): StorageSource;
29
+ has(key: string): boolean;
30
+ list(): string[];
31
+ /**
32
+ * Build a registry from `StorageSourceDefinition[]` and an HTTP transport.
33
+ *
34
+ * - Sources with `transport: "server"` are auto-wired via `createStorage(transport, key)`.
35
+ * - Sources with `transport: "direct"` are **not** auto-wired — they must
36
+ * be registered manually after this call (e.g. via a Firebase hook).
37
+ *
38
+ * @param definitions - Array of storage source definitions
39
+ * @param transport - HTTP transport for server-backed sources
40
+ */
41
+ static fromDefinitions(definitions: StorageSourceDefinition[], transport: Transport): ClientStorageSourceRegistry;
42
+ }
package/dist/storage.d.ts CHANGED
@@ -1,3 +1,11 @@
1
1
  import { StorageSource } from "@rebasepro/types";
2
2
  import { Transport } from "./transport";
3
- export declare function createStorage(transport: Transport): StorageSource;
3
+ /**
4
+ * Create a StorageSource that talks to the Rebase backend REST API.
5
+ *
6
+ * @param transport - HTTP transport instance
7
+ * @param storageId - Optional storage-source key for multi-backend routing.
8
+ * When set, it is forwarded to the server so the correct
9
+ * `StorageController` is resolved from the registry.
10
+ */
11
+ export declare function createStorage(transport: Transport, storageId?: string): StorageSource;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.8.0",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -30,9 +30,9 @@
30
30
  "./package.json": "./package.json"
31
31
  },
32
32
  "dependencies": {
33
- "@rebasepro/common": "0.6.1",
34
- "@rebasepro/types": "0.6.1",
35
- "@rebasepro/utils": "0.6.1"
33
+ "@rebasepro/common": "0.8.0",
34
+ "@rebasepro/types": "0.8.0",
35
+ "@rebasepro/utils": "0.8.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@jest/globals": "^30.4.1",
@@ -68,6 +68,7 @@
68
68
  ],
69
69
  "testEnvironment": "node",
70
70
  "moduleNameMapper": {
71
+ "^@rebasepro/common$": "<rootDir>/../common/src/index.ts",
71
72
  "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
72
73
  "^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts"
73
74
  }
package/src/admin.ts CHANGED
@@ -53,6 +53,23 @@ export function createAdmin(transport: Transport, options?: CreateAdminOptions)
53
53
  });
54
54
  }
55
55
 
56
+ async function resetPassword(userId: string, options?: { password?: string }) {
57
+ return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean }>(
58
+ adminPath + "/users/" + encodeURIComponent(userId) + "/reset-password",
59
+ {
60
+ method: "POST",
61
+ ...(options?.password ? { body: JSON.stringify({ password: options.password }) } : {})
62
+ }
63
+ );
64
+ }
65
+
66
+ async function listRoles() {
67
+ return transport.request<{ roles: Array<{ id: string; name: string }> }>(
68
+ adminPath + "/roles",
69
+ { method: "GET" }
70
+ );
71
+ }
72
+
56
73
  async function bootstrap() {
57
74
  return transport.request<{ success: boolean; message: string; user: { uid: string; roles: string[] } }>(adminPath + "/bootstrap", {
58
75
  method: "POST"
@@ -66,6 +83,8 @@ export function createAdmin(transport: Transport, options?: CreateAdminOptions)
66
83
  createUser,
67
84
  updateUser,
68
85
  deleteUser,
86
+ resetPassword,
87
+ listRoles,
69
88
  bootstrap
70
89
  };
71
90
  }
@@ -0,0 +1,110 @@
1
+ import type { Transport } from "./transport";
2
+
3
+ // Re-define the types locally since they live in server-core, not in @rebasepro/types.
4
+ // These match the server-side types exactly.
5
+
6
+ /** A single permission entry scoping an API key to a collection and its allowed operations. */
7
+ export interface ApiKeyPermission {
8
+ collection: string;
9
+ operations: ("read" | "write" | "delete")[];
10
+ }
11
+
12
+ /** An API key with the secret portion masked (returned by list / get / update). */
13
+ export interface ApiKeyMasked {
14
+ id: string;
15
+ name: string;
16
+ key_prefix: string;
17
+ permissions: ApiKeyPermission[];
18
+ admin: boolean;
19
+ rate_limit: number | null;
20
+ created_by: string;
21
+ created_at: string;
22
+ updated_at: string;
23
+ last_used_at: string | null;
24
+ expires_at: string | null;
25
+ revoked_at: string | null;
26
+ }
27
+
28
+ /** An API key including the full secret (returned only on creation). */
29
+ export interface ApiKeyWithSecret extends ApiKeyMasked {
30
+ key: string;
31
+ }
32
+
33
+ /** Payload for creating a new API key. */
34
+ export interface CreateApiKeyRequest {
35
+ name: string;
36
+ permissions: ApiKeyPermission[];
37
+ rate_limit?: number | null;
38
+ expires_at?: string | null;
39
+ }
40
+
41
+ /** Payload for updating an existing API key. */
42
+ export interface UpdateApiKeyRequest {
43
+ name?: string;
44
+ permissions?: ApiKeyPermission[];
45
+ rate_limit?: number | null;
46
+ expires_at?: string | null;
47
+ }
48
+
49
+ /** Options for the `createApiKeys` factory. */
50
+ export interface CreateApiKeysOptions {
51
+ apiKeysPath?: string;
52
+ }
53
+
54
+ /**
55
+ * Creates a client for managing API keys via the admin routes.
56
+ *
57
+ * @param transport - The shared HTTP transport created by `createTransport`.
58
+ * @param options - Optional overrides (e.g. a custom base path).
59
+ */
60
+ export function createApiKeys(transport: Transport, options?: CreateApiKeysOptions) {
61
+ const apiKeysPath = options?.apiKeysPath || "/admin/api-keys";
62
+
63
+ /** List all API keys (masked). */
64
+ async function listKeys(): Promise<{ keys: ApiKeyMasked[] }> {
65
+ return transport.request<{ keys: ApiKeyMasked[] }>(apiKeysPath, { method: "GET" });
66
+ }
67
+
68
+ /** Get a single API key by ID (masked). */
69
+ async function getKey(id: string): Promise<{ key: ApiKeyMasked }> {
70
+ return transport.request<{ key: ApiKeyMasked }>(
71
+ apiKeysPath + "/" + encodeURIComponent(id),
72
+ { method: "GET" }
73
+ );
74
+ }
75
+
76
+ /** Create a new API key. The full secret is included in the response. */
77
+ async function createKey(data: CreateApiKeyRequest): Promise<{ key: ApiKeyWithSecret }> {
78
+ return transport.request<{ key: ApiKeyWithSecret }>(apiKeysPath, {
79
+ method: "POST",
80
+ body: JSON.stringify(data)
81
+ });
82
+ }
83
+
84
+ /** Update an existing API key. */
85
+ async function updateKey(id: string, data: UpdateApiKeyRequest): Promise<{ key: ApiKeyMasked }> {
86
+ return transport.request<{ key: ApiKeyMasked }>(
87
+ apiKeysPath + "/" + encodeURIComponent(id),
88
+ {
89
+ method: "PUT",
90
+ body: JSON.stringify(data)
91
+ }
92
+ );
93
+ }
94
+
95
+ /** Revoke (soft-delete) an API key. */
96
+ async function revokeKey(id: string): Promise<{ success: boolean }> {
97
+ return transport.request<{ success: boolean }>(
98
+ apiKeysPath + "/" + encodeURIComponent(id),
99
+ { method: "DELETE" }
100
+ );
101
+ }
102
+
103
+ return {
104
+ listKeys,
105
+ getKey,
106
+ createKey,
107
+ updateKey,
108
+ revokeKey
109
+ };
110
+ }
package/src/auth.ts CHANGED
@@ -35,6 +35,7 @@ export interface AuthConfig {
35
35
  emailServiceEnabled?: boolean;
36
36
  passwordReset?: boolean;
37
37
  emailVerification?: boolean;
38
+ magicLink?: boolean;
38
39
  enabledProviders: string[];
39
40
  }
40
41
 
@@ -428,6 +429,33 @@ newPassword })
428
429
  return body as { success: boolean; message: string; };
429
430
  }
430
431
 
432
+ async function sendMagicLink(email: string) {
433
+ const fetchFn = getFetch();
434
+ const res = await fetchFn(authUrl("/magic-link"), {
435
+ method: "POST",
436
+ headers: { "Content-Type": "application/json" },
437
+ body: JSON.stringify({ email })
438
+ });
439
+ const body = await res.json().catch(() => ({}));
440
+ if (!res.ok) throwApiError(res.status, body, res.statusText);
441
+ return body as { success: boolean; message: string; };
442
+ }
443
+
444
+ async function verifyMagicLink(token: string) {
445
+ const fetchFn = getFetch();
446
+ const res = await fetchFn(authUrl("/magic-link/verify"), {
447
+ method: "POST",
448
+ headers: { "Content-Type": "application/json" },
449
+ body: JSON.stringify({ token })
450
+ });
451
+ const body = await res.json().catch(() => ({}));
452
+ if (!res.ok) throwApiError(res.status, body, res.statusText);
453
+ const session = handleAuthResponse(body, "SIGNED_IN");
454
+ return { user: session.user,
455
+ accessToken: session.accessToken,
456
+ refreshToken: session.refreshToken };
457
+ }
458
+
431
459
  async function getSessions() {
432
460
  const data = await transport.request<{ sessions: Record<string, unknown>[] }>(authPath + "/sessions", { method: "GET" });
433
461
  return data.sessions;
@@ -517,6 +545,8 @@ newPassword })
517
545
  changePassword,
518
546
  sendVerificationEmail,
519
547
  verifyEmail,
548
+ sendMagicLink,
549
+ verifyMagicLink,
520
550
  getSessions,
521
551
  revokeSession,
522
552
  revokeAllSessions,
@@ -42,7 +42,7 @@ describe("createCollectionClient", () => {
42
42
 
43
43
  const client = createCollectionClient(transport, "products");
44
44
  const result = await client.count({
45
- where: { status: "eq.published" }
45
+ where: { status: ["==", "published"] }
46
46
  });
47
47
 
48
48
  expect(transport.request).toHaveBeenCalledWith(
@@ -111,7 +111,7 @@ offset: 10 });
111
111
  const client = createCollectionClient(transport, "orders");
112
112
  await client.count({
113
113
  where: {
114
- status: "eq.active",
114
+ status: ["==", "active"],
115
115
  total: [">=", 100]
116
116
  }
117
117
  });
package/src/collection.ts CHANGED
@@ -3,125 +3,15 @@ import { RebaseWebSocketClient } from "./websocket";
3
3
  import {
4
4
  CollectionAccessor,
5
5
  Entity,
6
- FilterOperator,
7
6
  FilterValues,
8
7
  FindResponse,
9
- WhereFieldValue,
10
- WhereFilterOp,
11
8
  LogicalCondition,
9
+ WhereFilterOp,
12
10
  WhereValue
13
11
  } from "@rebasepro/types";
14
12
 
15
13
  import { QueryBuilder } from "./query_builder";
16
14
 
17
- function parseWhereFilter(where?: Record<string, WhereFieldValue>): FilterValues<string> | undefined {
18
- if (!where) return undefined;
19
- const filters: Record<string, any> = {};
20
-
21
- const OP_TO_FILTER: Record<string, WhereFilterOp> = {
22
- "eq": "==",
23
- "neq": "!=",
24
- "gt": ">",
25
- "gte": ">=",
26
- "lt": "<",
27
- "lte": "<=",
28
- "==": "==",
29
- "!=": "!=",
30
- ">": ">",
31
- ">=": ">=",
32
- "<": "<",
33
- "<=": "<=",
34
- "in": "in",
35
- "nin": "not-in",
36
- "not-in": "not-in",
37
- "cs": "array-contains",
38
- "csa": "array-contains-any",
39
- "array-contains": "array-contains",
40
- "array-contains-any": "array-contains-any"
41
- };
42
-
43
- const parseSingle = (rawValue: any, fieldKey: string): [WhereFilterOp, unknown] => {
44
- if (rawValue === null) return ["==", null];
45
- if (typeof rawValue === "boolean") return ["==", rawValue];
46
- if (typeof rawValue === "number") return ["==", rawValue];
47
-
48
- if (Array.isArray(rawValue) && rawValue.length === 2 && typeof rawValue[0] === "string") {
49
- const [rawOp, val] = rawValue;
50
- return [OP_TO_FILTER[rawOp] ?? "==", val];
51
- }
52
-
53
- const value = String(rawValue);
54
- const dotIndex = value.indexOf(".");
55
- if (dotIndex > 0) {
56
- const opStr = value.substring(0, dotIndex);
57
- const valStr = value.substring(dotIndex + 1);
58
- let op: WhereFilterOp = "==";
59
- let val: string | number | boolean | null | string[] = valStr;
60
-
61
- switch (opStr) {
62
- case "eq":
63
- op = "==";
64
- break;
65
- case "neq":
66
- op = "!=";
67
- break;
68
- case "gt":
69
- op = ">";
70
- break;
71
- case "gte":
72
- op = ">=";
73
- break;
74
- case "lt":
75
- op = "<";
76
- break;
77
- case "lte":
78
- op = "<=";
79
- break;
80
- case "in":
81
- op = "in";
82
- val = valStr.startsWith("(") && valStr.endsWith(")")
83
- ? valStr.slice(1, -1).split(",").map(v => v.trim())
84
- : valStr.split(",");
85
- break;
86
- case "nin":
87
- op = "not-in";
88
- val = valStr.startsWith("(") && valStr.endsWith(")")
89
- ? valStr.slice(1, -1).split(",").map(v => v.trim())
90
- : valStr.split(",");
91
- break;
92
- case "cs":
93
- op = "array-contains";
94
- break;
95
- case "csa":
96
- op = "array-contains-any";
97
- val = valStr.startsWith("(") && valStr.endsWith(")")
98
- ? valStr.slice(1, -1).split(",").map(v => v.trim())
99
- : valStr.split(",");
100
- break;
101
- default:
102
- op = "==";
103
- val = value;
104
- }
105
- if (val === "true") val = true;
106
- else if (val === "false") val = false;
107
- else if (val === "null") val = null;
108
- else if (typeof val === "string" && /^[0-9]+(\.[0-9]+)?$/.test(val) && fieldKey !== "id" && !fieldKey.endsWith("_id")) val = Number(val);
109
-
110
- return [op, val];
111
- } else {
112
- return ["==", value];
113
- }
114
- };
115
-
116
- for (const [key, rawValue] of Object.entries(where)) {
117
- if (Array.isArray(rawValue) && rawValue.length > 0 && Array.isArray(rawValue[0])) {
118
- filters[key] = rawValue.map(r => parseSingle(r, key));
119
- } else {
120
- filters[key] = parseSingle(rawValue, key);
121
- }
122
- }
123
- return filters;
124
- }
125
15
 
126
16
  /**
127
17
  * Wrap a flat row (returned by the REST API as `{ id, ...fields }`) into
@@ -144,10 +34,10 @@ function rowToEntity<M extends Record<string, unknown>>(row: Record<string, unkn
144
34
  * Additionally it exposes fluent query builder methods like `.where()`, `.orderBy()`.
145
35
  */
146
36
  export interface CollectionClient<M extends Record<string, unknown> = Record<string, unknown>> extends CollectionAccessor<M> {
147
- where<K extends keyof M & string>(column: K, operator: FilterOperator, value: WhereValue<M[K]>): QueryBuilder<M>;
37
+ where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): QueryBuilder<M>;
148
38
  where(logicalCondition: LogicalCondition): QueryBuilder<M>;
149
39
 
150
- orderBy(column: keyof M & string, ascending?: "asc" | "desc"): QueryBuilder<M>;
40
+ orderBy(column: keyof M & string, direction?: "asc" | "desc"): QueryBuilder<M>;
151
41
 
152
42
  limit(count: number): QueryBuilder<M>;
153
43
 
@@ -227,15 +117,15 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
227
117
  },
228
118
 
229
119
  // Fluent builder instantiation
230
- where(columnOrCondition: string | LogicalCondition, operator?: FilterOperator, value?: unknown) {
120
+ where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
231
121
  const builder = new QueryBuilder<M>(client as unknown as CollectionAccessor<M>);
232
122
  if (typeof columnOrCondition === "object") {
233
123
  return builder.where(columnOrCondition);
234
124
  }
235
125
  return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
236
126
  },
237
- orderBy(column: keyof M & string, ascending?: "asc" | "desc") {
238
- return new QueryBuilder<M>(client).orderBy(column, ascending);
127
+ orderBy(column: keyof M & string, direction?: "asc" | "desc") {
128
+ return new QueryBuilder<M>(client).orderBy(column, direction);
239
129
  },
240
130
  limit(count: number) {
241
131
  return new QueryBuilder<M>(client).limit(count);
@@ -253,10 +143,12 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
253
143
 
254
144
  if (ws) {
255
145
  client.listen = (params: FindParams | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
256
- return ws.listenCollection(
146
+ let active = true;
147
+ let lastUpdateId = 0;
148
+ const unsub = ws.listenCollection(
257
149
  {
258
150
  path: slug,
259
- filter: parseWhereFilter(params?.where),
151
+ filter: params?.where,
260
152
  limit: params?.limit,
261
153
  startAfter: params?.offset ? String(params.offset) : undefined,
262
154
  orderBy: params?.orderBy?.split(":")[0],
@@ -264,19 +156,49 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
264
156
  searchString: params?.searchString
265
157
  },
266
158
  (entities: Entity[]) => {
159
+ const currentUpdateId = ++lastUpdateId;
267
160
  const requestedLimit = params?.limit || 20;
161
+ const offset = params?.offset || 0;
162
+
163
+ // Immediately fire update with heuristic metadata
268
164
  onUpdate({
269
165
  data: entities as Entity<M>[],
270
166
  meta: {
271
167
  total: entities.length,
272
168
  limit: requestedLimit,
273
- offset: params?.offset || 0,
169
+ offset,
274
170
  hasMore: entities.length >= requestedLimit
275
171
  }
276
172
  });
173
+
174
+ // Asynchronously fetch the actual count from the server to get accurate total/hasMore
175
+ if (client.count) {
176
+ client.count(params)
177
+ .then((total) => {
178
+ if (active && currentUpdateId === lastUpdateId) {
179
+ onUpdate({
180
+ data: entities as Entity<M>[],
181
+ meta: {
182
+ total,
183
+ limit: requestedLimit,
184
+ offset,
185
+ hasMore: offset + entities.length < total
186
+ }
187
+ });
188
+ }
189
+ })
190
+ .catch(() => {
191
+ // Silent fallback on count error
192
+ });
193
+ }
277
194
  },
278
195
  onError
279
196
  );
197
+
198
+ return () => {
199
+ active = false;
200
+ unsub();
201
+ };
280
202
  };
281
203
 
282
204
  client.listenById = (id: string | number, onUpdate: (data: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
package/src/index.ts CHANGED
@@ -2,21 +2,32 @@ import { createTransport, RebaseClientConfig } from "./transport";
2
2
  import { createAuth, CreateAuthOptions } from "./auth";
3
3
  import { createAdmin, CreateAdminOptions } from "./admin";
4
4
  import { createCron, CreateCronOptions } from "./cron";
5
- import { createCollectionClient, CollectionClient } from "./collection";
5
+ import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
6
+ import { CollectionClient, createCollectionClient } from "./collection";
6
7
  import { createFunctionsClient } from "./functions";
7
8
  import { createStorage } from "./storage";
9
+ import { ClientStorageSourceRegistry } from "./storage-registry";
8
10
  import { RebaseWebSocketClient } from "./websocket";
9
- import { RebaseClient, RebaseData, StorageSource } from "@rebasepro/types";
11
+ import {
12
+ DEFAULT_STORAGE_SOURCE_KEY,
13
+ RebaseClient,
14
+ RebaseData,
15
+ StorageSource,
16
+ StorageSourceDefinition,
17
+ StorageSourceRegistry
18
+ } from "@rebasepro/types";
10
19
  import { toSnakeCase } from "@rebasepro/utils";
11
20
 
12
21
  export * from "./transport";
13
22
  export * from "./auth";
14
23
  export * from "./admin";
15
24
  export * from "./cron";
25
+ export * from "./api-keys";
16
26
  export * from "./collection";
17
27
  export * from "./query_builder";
18
28
  export * from "./websocket";
19
29
  export * from "./storage";
30
+ export * from "./storage-registry";
20
31
  export * from "./reviver";
21
32
  export * from "./functions";
22
33
  export type { Entity, FindResponse } from "@rebasepro/types";
@@ -25,6 +36,22 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
25
36
  auth?: CreateAuthOptions;
26
37
  admin?: CreateAdminOptions;
27
38
  cron?: CreateCronOptions;
39
+ apiKeys?: CreateApiKeysOptions;
40
+ /**
41
+ * Declared storage sources for multi-backend support. Server-transport
42
+ * entries are auto-wired into `client.storageRegistry`; `direct` sources
43
+ * are registered app-side (e.g. via a Firebase Storage hook). The default
44
+ * source (`storage`) is always registered under
45
+ * {@link DEFAULT_STORAGE_SOURCE_KEY}.
46
+ */
47
+ storageSources?: StorageSourceDefinition[];
48
+ /**
49
+ * Maps camelCase property names / safe identifiers to the actual
50
+ * collection slugs on the server (e.g. `{ companyMembers: "company-members" }`).
51
+ * If provided, the data layer proxy will resolve property accessors to their
52
+ * correct slugs via this map before falling back to automatic snake_casing.
53
+ */
54
+ collections?: Record<string, string>;
28
55
  }
29
56
 
30
57
  // ─── Typed Data Proxy ────────────────────────────────────────────────────────
@@ -62,9 +89,13 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
62
89
  auth: ReturnType<typeof createAuth>;
63
90
  admin: ReturnType<typeof createAdmin>;
64
91
  cron: ReturnType<typeof createCron>;
92
+ apiKeys: ReturnType<typeof createApiKeys>;
65
93
  functions: ReturnType<typeof createFunctionsClient>;
66
94
  ws?: RebaseWebSocketClient;
67
95
  storage: StorageSource;
96
+ storageRegistry: StorageSourceRegistry;
97
+ createStorageSource: (storageId: string) => StorageSource;
98
+ fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
68
99
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
69
100
  data: TypedDataLayer<DB>;
70
101
  };
@@ -110,9 +141,52 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
110
141
  const auth = createAuth(transport, options.auth);
111
142
  const admin = createAdmin(transport, options.admin);
112
143
  const cron = createCron(transport, options.cron);
144
+ const apiKeys = createApiKeys(transport, options.apiKeys);
113
145
  const storage = createStorage(transport);
114
146
  const functions = createFunctionsClient(transport);
115
147
 
148
+ // Build a server-backed StorageSource for a given storage-source key.
149
+ const createStorageSource = (storageId: string): StorageSource =>
150
+ storageId === DEFAULT_STORAGE_SOURCE_KEY ? storage : createStorage(transport, storageId);
151
+
152
+ // Storage registry: always holds the default source, plus any declared
153
+ // server-transport sources. `direct` sources are registered app-side.
154
+ const storageRegistry = new ClientStorageSourceRegistry();
155
+ storageRegistry.register(DEFAULT_STORAGE_SOURCE_KEY, storage);
156
+ for (const def of options.storageSources ?? []) {
157
+ if (def.transport === "server" && def.key !== DEFAULT_STORAGE_SOURCE_KEY) {
158
+ storageRegistry.register(def.key, createStorageSource(def.key));
159
+ }
160
+ }
161
+
162
+ // Discover storage sources from the backend, making the server the single
163
+ // source of truth. Server-transport sources are auto-wired into the
164
+ // registry; `direct` sources are returned for the app to register. The
165
+ // promise is cached on success and reset on failure so it can be retried
166
+ // (e.g. once the user authenticates).
167
+ let storageSourcesPromise: Promise<StorageSourceDefinition[]> | undefined;
168
+ const fetchStorageSources = (): Promise<StorageSourceDefinition[]> => {
169
+ if (storageSourcesPromise) return storageSourcesPromise;
170
+ storageSourcesPromise = transport
171
+ .request<{ data: StorageSourceDefinition[] }>("/storage/sources")
172
+ .then((res) => {
173
+ const defs = res.data ?? [];
174
+ for (const def of defs) {
175
+ if (def.transport === "server"
176
+ && def.key !== DEFAULT_STORAGE_SOURCE_KEY
177
+ && !storageRegistry.has(def.key)) {
178
+ storageRegistry.register(def.key, createStorageSource(def.key));
179
+ }
180
+ }
181
+ return defs;
182
+ })
183
+ .catch((e) => {
184
+ storageSourcesPromise = undefined; // allow retry
185
+ throw e;
186
+ });
187
+ return storageSourcesPromise;
188
+ };
189
+
116
190
  const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
117
191
 
118
192
  let ws: RebaseWebSocketClient | undefined;
@@ -185,6 +259,9 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
185
259
  }
186
260
  if (typeof prop === "symbol") return undefined;
187
261
  if (typeof prop === "string" && prop !== "then" && prop !== "toJSON" && prop !== "$$typeof") {
262
+ if (options.collections && prop in options.collections) {
263
+ return collection(options.collections[prop]);
264
+ }
188
265
  // Convert camelCase property names to snake_case slugs.
189
266
  // e.g. `companyMembers` → `company_members`
190
267
  const slug = toSnakeCase(prop);
@@ -198,8 +275,12 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
198
275
  auth,
199
276
  admin,
200
277
  cron,
278
+ apiKeys,
201
279
  functions,
202
280
  storage,
281
+ storageRegistry,
282
+ createStorageSource,
283
+ fetchStorageSources,
203
284
  ws,
204
285
  setToken: transport.setToken,
205
286
  setAuthTokenGetter: transport.setAuthTokenGetter,