@rebasepro/client 0.7.0 → 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.7.0",
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"
@@ -20,13 +20,6 @@
20
20
  "engines": {
21
21
  "node": ">=14"
22
22
  },
23
- "scripts": {
24
- "watch": "vite build --watch",
25
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
26
- "test:lint": "eslint \"src/**\" --quiet",
27
- "test": "jest --passWithNoTests --forceExit",
28
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
29
- },
30
23
  "exports": {
31
24
  ".": {
32
25
  "types": "./dist/index.d.ts",
@@ -37,9 +30,9 @@
37
30
  "./package.json": "./package.json"
38
31
  },
39
32
  "dependencies": {
40
- "@rebasepro/common": "workspace:*",
41
- "@rebasepro/types": "workspace:*",
42
- "@rebasepro/utils": "workspace:*"
33
+ "@rebasepro/common": "0.8.0",
34
+ "@rebasepro/types": "0.8.0",
35
+ "@rebasepro/utils": "0.8.0"
43
36
  },
44
37
  "devDependencies": {
45
38
  "@jest/globals": "^30.4.1",
@@ -75,8 +68,16 @@
75
68
  ],
76
69
  "testEnvironment": "node",
77
70
  "moduleNameMapper": {
71
+ "^@rebasepro/common$": "<rootDir>/../common/src/index.ts",
78
72
  "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
79
73
  "^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts"
80
74
  }
75
+ },
76
+ "scripts": {
77
+ "watch": "vite build --watch",
78
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
79
+ "test:lint": "eslint \"src/**\" --quiet",
80
+ "test": "jest --passWithNoTests --forceExit",
81
+ "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
81
82
  }
82
- }
83
+ }
package/src/api-keys.ts CHANGED
@@ -15,6 +15,7 @@ export interface ApiKeyMasked {
15
15
  name: string;
16
16
  key_prefix: string;
17
17
  permissions: ApiKeyPermission[];
18
+ admin: boolean;
18
19
  rate_limit: number | null;
19
20
  created_by: string;
20
21
  created_at: string;
@@ -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
@@ -3,11 +3,19 @@ import { createAuth, CreateAuthOptions } from "./auth";
3
3
  import { createAdmin, CreateAdminOptions } from "./admin";
4
4
  import { createCron, CreateCronOptions } from "./cron";
5
5
  import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
6
- import { createCollectionClient, CollectionClient } from "./collection";
6
+ import { CollectionClient, createCollectionClient } from "./collection";
7
7
  import { createFunctionsClient } from "./functions";
8
8
  import { createStorage } from "./storage";
9
+ import { ClientStorageSourceRegistry } from "./storage-registry";
9
10
  import { RebaseWebSocketClient } from "./websocket";
10
- 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";
11
19
  import { toSnakeCase } from "@rebasepro/utils";
12
20
 
13
21
  export * from "./transport";
@@ -19,6 +27,7 @@ export * from "./collection";
19
27
  export * from "./query_builder";
20
28
  export * from "./websocket";
21
29
  export * from "./storage";
30
+ export * from "./storage-registry";
22
31
  export * from "./reviver";
23
32
  export * from "./functions";
24
33
  export type { Entity, FindResponse } from "@rebasepro/types";
@@ -28,6 +37,21 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
28
37
  admin?: CreateAdminOptions;
29
38
  cron?: CreateCronOptions;
30
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>;
31
55
  }
32
56
 
33
57
  // ─── Typed Data Proxy ────────────────────────────────────────────────────────
@@ -69,6 +93,9 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
69
93
  functions: ReturnType<typeof createFunctionsClient>;
70
94
  ws?: RebaseWebSocketClient;
71
95
  storage: StorageSource;
96
+ storageRegistry: StorageSourceRegistry;
97
+ createStorageSource: (storageId: string) => StorageSource;
98
+ fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
72
99
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
73
100
  data: TypedDataLayer<DB>;
74
101
  };
@@ -118,6 +145,48 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
118
145
  const storage = createStorage(transport);
119
146
  const functions = createFunctionsClient(transport);
120
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
+
121
190
  const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
122
191
 
123
192
  let ws: RebaseWebSocketClient | undefined;
@@ -190,6 +259,9 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
190
259
  }
191
260
  if (typeof prop === "symbol") return undefined;
192
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
+ }
193
265
  // Convert camelCase property names to snake_case slugs.
194
266
  // e.g. `companyMembers` → `company_members`
195
267
  const slug = toSnakeCase(prop);
@@ -206,6 +278,9 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
206
278
  apiKeys,
207
279
  functions,
208
280
  storage,
281
+ storageRegistry,
282
+ createStorageSource,
283
+ fetchStorageSources,
209
284
  ws,
210
285
  setToken: transport.setToken,
211
286
  setAuthTokenGetter: transport.setAuthTokenGetter,
@@ -0,0 +1,102 @@
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
+
14
+ import type { StorageSource, StorageSourceRegistry, StorageSourceDefinition } from "@rebasepro/types";
15
+ import { DEFAULT_STORAGE_SOURCE_KEY } from "@rebasepro/types";
16
+ import { createStorage } from "./storage";
17
+ import type { Transport } from "./transport";
18
+
19
+ /**
20
+ * Default implementation of the client-side `StorageSourceRegistry`.
21
+ */
22
+ export class ClientStorageSourceRegistry implements StorageSourceRegistry {
23
+ private sources = new Map<string, StorageSource>();
24
+
25
+ /**
26
+ * Register a storage source.
27
+ * @param key - Unique key matching a `StorageSourceDefinition.key`
28
+ * @param source - The `StorageSource` instance
29
+ */
30
+ register(key: string, source: StorageSource): void {
31
+ this.sources.set(key, source);
32
+ }
33
+
34
+ getDefault(): StorageSource {
35
+ const source = this.sources.get(DEFAULT_STORAGE_SOURCE_KEY);
36
+ if (!source) {
37
+ throw new Error(
38
+ `[StorageSourceRegistry] No default storage source registered. ` +
39
+ `Register one with key "${DEFAULT_STORAGE_SOURCE_KEY}".`
40
+ );
41
+ }
42
+ return source;
43
+ }
44
+
45
+ get(key: string | undefined | null): StorageSource | undefined {
46
+ if (key === undefined || key === null) {
47
+ return this.sources.get(DEFAULT_STORAGE_SOURCE_KEY);
48
+ }
49
+ return this.sources.get(key);
50
+ }
51
+
52
+ getOrDefault(key: string | undefined | null): StorageSource {
53
+ if (key === undefined || key === null) {
54
+ return this.getDefault();
55
+ }
56
+ const source = this.sources.get(key);
57
+ if (source) return source;
58
+
59
+ // Fallback to default
60
+ console.warn(
61
+ `[StorageSourceRegistry] Storage source "${key}" not found, ` +
62
+ `falling back to "${DEFAULT_STORAGE_SOURCE_KEY}".`
63
+ );
64
+ return this.getDefault();
65
+ }
66
+
67
+ has(key: string): boolean {
68
+ return this.sources.has(key);
69
+ }
70
+
71
+ list(): string[] {
72
+ return Array.from(this.sources.keys());
73
+ }
74
+
75
+ /**
76
+ * Build a registry from `StorageSourceDefinition[]` and an HTTP transport.
77
+ *
78
+ * - Sources with `transport: "server"` are auto-wired via `createStorage(transport, key)`.
79
+ * - Sources with `transport: "direct"` are **not** auto-wired — they must
80
+ * be registered manually after this call (e.g. via a Firebase hook).
81
+ *
82
+ * @param definitions - Array of storage source definitions
83
+ * @param transport - HTTP transport for server-backed sources
84
+ */
85
+ static fromDefinitions(
86
+ definitions: StorageSourceDefinition[],
87
+ transport: Transport
88
+ ): ClientStorageSourceRegistry {
89
+ const registry = new ClientStorageSourceRegistry();
90
+
91
+ for (const def of definitions) {
92
+ if (def.transport === "server") {
93
+ // Auto-create a server-backed StorageSource for this key
94
+ const source = createStorage(transport, def.key === DEFAULT_STORAGE_SOURCE_KEY ? undefined : def.key);
95
+ registry.register(def.key, source);
96
+ }
97
+ // "direct" sources must be registered manually
98
+ }
99
+
100
+ return registry;
101
+ }
102
+ }