@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.
- package/dist/admin.d.ts +13 -0
- package/dist/api-keys.d.ts +66 -0
- package/dist/auth.d.ts +10 -0
- package/dist/collection.d.ts +3 -3
- package/dist/index.d.ts +24 -1
- package/dist/index.es.js +218 -167
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +222 -169
- package/dist/index.umd.js.map +1 -1
- package/dist/storage-registry.d.ts +42 -0
- package/dist/storage.d.ts +9 -1
- package/package.json +5 -4
- package/src/admin.ts +19 -0
- package/src/api-keys.ts +110 -0
- package/src/auth.ts +30 -0
- package/src/collection.test.ts +2 -2
- package/src/collection.ts +41 -119
- package/src/index.ts +83 -2
- package/src/storage-registry.ts +102 -0
- package/src/storage.ts +30 -20
- package/src/transport.ts +8 -82
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
34
|
-
"@rebasepro/types": "0.
|
|
35
|
-
"@rebasepro/utils": "0.
|
|
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
|
}
|
package/src/api-keys.ts
ADDED
|
@@ -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,
|
package/src/collection.test.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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:
|
|
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,
|
|
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?:
|
|
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,
|
|
238
|
-
return new QueryBuilder<M>(client).orderBy(column,
|
|
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
|
-
|
|
146
|
+
let active = true;
|
|
147
|
+
let lastUpdateId = 0;
|
|
148
|
+
const unsub = ws.listenCollection(
|
|
257
149
|
{
|
|
258
150
|
path: slug,
|
|
259
|
-
filter:
|
|
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
|
|
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 {
|
|
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 {
|
|
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,
|