@bunbase-ae/js 1.0.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/src/auth.ts ADDED
@@ -0,0 +1,338 @@
1
+ // Auth client — register, login, refresh, logout, me.
2
+ //
3
+ // Tokens are stored in HttpClient (the shared token store). AuthClient only
4
+ // handles the API calls and wires the results back into HttpClient.
5
+
6
+ import type { HttpClient } from "./http";
7
+ import type { ApiKey, AuthResult, AuthUser, LoginResult, TotpChallenge, TotpSetup } from "./types";
8
+
9
+ // Reactive snapshot of auth state. Consumed by useAuth() via useSyncExternalStore.
10
+ export interface AuthSnapshot {
11
+ user: AuthUser | null;
12
+ loading: boolean;
13
+ error: Error | null;
14
+ }
15
+
16
+ export class AuthClient {
17
+ // Cached user — set on every successful login/register/me/verifyTotp call.
18
+ // Cleared on logout. Lets useAuth() initialize synchronously without a network round-trip.
19
+ private cachedUser: AuthUser | null = null;
20
+
21
+ // External store for useSyncExternalStore — holds user/loading/error as a snapshot.
22
+ private _snap: AuthSnapshot = { user: null, loading: false, error: null };
23
+ private _snapSubs = new Set<() => void>();
24
+
25
+ constructor(private readonly http: HttpClient) {
26
+ // When tokens are cleared from ANY path (logout, logoutAll, deleteAccount,
27
+ // or server-pushed revocation via RealtimeClient), keep cachedUser and the
28
+ // reactive snapshot in sync automatically.
29
+ this.http.onTokenChange(({ accessToken }) => {
30
+ if (!accessToken && this.cachedUser !== null) {
31
+ this.cachedUser = null;
32
+ this.patchSnapshot({ user: null });
33
+ }
34
+ });
35
+ }
36
+
37
+ /** Current snapshot — stable reference until something changes. */
38
+ getSnapshot(): AuthSnapshot {
39
+ return this._snap;
40
+ }
41
+
42
+ /** Subscribe to snapshot changes. Returns unsubscribe fn. */
43
+ subscribeSnapshot(listener: () => void): () => void {
44
+ this._snapSubs.add(listener);
45
+ return () => {
46
+ this._snapSubs.delete(listener);
47
+ };
48
+ }
49
+
50
+ /** Merge patch into the snapshot and notify subscribers only if something actually changed. */
51
+ patchSnapshot(patch: Partial<AuthSnapshot>): void {
52
+ let changed = false;
53
+ for (const key of Object.keys(patch) as (keyof AuthSnapshot)[]) {
54
+ if (this._snap[key] !== patch[key]) {
55
+ changed = true;
56
+ break;
57
+ }
58
+ }
59
+ if (!changed) return;
60
+ this._snap = { ...this._snap, ...patch };
61
+ for (const fn of this._snapSubs) fn();
62
+ }
63
+
64
+ /** Returns the last known user synchronously, or null if not yet loaded. */
65
+ getCachedUser(): AuthUser | null {
66
+ return this.cachedUser;
67
+ }
68
+
69
+ async register(credentials: { email: string; password: string }): Promise<AuthResult> {
70
+ const result = await this.http.request<AuthResult>("POST", "/api/v1/auth/register", {
71
+ body: credentials,
72
+ skipAuth: true,
73
+ });
74
+ this.cachedUser = result.user;
75
+ this.patchSnapshot({ user: result.user });
76
+ this.http.setTokens(result.access_token, result.refresh_token);
77
+ return result;
78
+ }
79
+
80
+ // Returns AuthResult on success, or TotpChallenge when 2FA is enabled.
81
+ // On TotpChallenge, call verifyTotp(result.totp_token, code) to complete sign-in.
82
+ async login(credentials: { email: string; password: string }): Promise<LoginResult> {
83
+ const result = await this.http.request<LoginResult>("POST", "/api/v1/auth/login", {
84
+ body: credentials,
85
+ skipAuth: true,
86
+ });
87
+ if ("totp_required" in result) return result as TotpChallenge;
88
+ this.cachedUser = (result as AuthResult).user;
89
+ this.patchSnapshot({ user: (result as AuthResult).user });
90
+ this.http.setTokens(result.access_token, result.refresh_token);
91
+ return result;
92
+ }
93
+
94
+ // Complete 2FA login. totpToken comes from the TotpChallenge returned by login().
95
+ async verifyTotp(totpToken: string, code: string): Promise<AuthResult> {
96
+ const result = await this.http.request<AuthResult>("POST", "/api/v1/auth/2fa/verify", {
97
+ body: { totp_token: totpToken, code },
98
+ skipAuth: true,
99
+ });
100
+ this.cachedUser = result.user;
101
+ this.patchSnapshot({ user: result.user });
102
+ this.http.setTokens(result.access_token, result.refresh_token);
103
+ return result;
104
+ }
105
+
106
+ // Send a magic-link login email. The link routes to your app with ?token=...
107
+ // Pass the token to verifyMagicLink() to complete sign-in.
108
+ async requestMagicLink(email: string): Promise<void> {
109
+ await this.http.request<{ ok: boolean }>("POST", "/api/v1/auth/magic-link", {
110
+ body: { email },
111
+ skipAuth: true,
112
+ });
113
+ }
114
+
115
+ // Complete magic-link sign-in using the token from the email URL.
116
+ async verifyMagicLink(token: string): Promise<AuthResult> {
117
+ const result = await this.http.request<AuthResult>("POST", "/api/v1/auth/magic-link/verify", {
118
+ body: { token },
119
+ skipAuth: true,
120
+ });
121
+ this.cachedUser = result.user;
122
+ this.patchSnapshot({ user: result.user });
123
+ this.http.setTokens(result.access_token, result.refresh_token);
124
+ return result;
125
+ }
126
+
127
+ async refresh(): Promise<AuthResult> {
128
+ const refreshToken = this.http.getRefreshToken();
129
+ if (!refreshToken) throw new Error("No refresh token stored. Call login() first.");
130
+ const result = await this.http.request<AuthResult>("POST", "/api/v1/auth/refresh", {
131
+ body: { refresh_token: refreshToken },
132
+ skipAuth: true,
133
+ });
134
+ this.cachedUser = result.user;
135
+ this.patchSnapshot({ user: result.user });
136
+ this.http.setTokens(result.access_token, result.refresh_token);
137
+ return result;
138
+ }
139
+
140
+ async logout(): Promise<void> {
141
+ const refreshToken = this.http.getRefreshToken();
142
+ try {
143
+ if (refreshToken) {
144
+ await this.http.request<void>("POST", "/api/v1/auth/logout", {
145
+ body: { refresh_token: refreshToken },
146
+ skipAuth: true,
147
+ });
148
+ }
149
+ } finally {
150
+ this.cachedUser = null;
151
+ this.patchSnapshot({ user: null });
152
+ this.http.clearTokens();
153
+ }
154
+ }
155
+
156
+ // Revoke all active sessions for the current user.
157
+ async logoutAll(): Promise<{ sessions_revoked: number }> {
158
+ const result = await this.http.request<{ ok: boolean; sessions_revoked: number }>(
159
+ "POST",
160
+ "/api/v1/auth/logout-all",
161
+ );
162
+ this.cachedUser = null;
163
+ this.patchSnapshot({ user: null });
164
+ this.http.clearTokens();
165
+ return { sessions_revoked: result.sessions_revoked };
166
+ }
167
+
168
+ async me(): Promise<AuthUser> {
169
+ const user = await this.http.request<AuthUser>("GET", "/api/v1/auth/me");
170
+ this.cachedUser = user;
171
+ this.patchSnapshot({ user });
172
+ return user;
173
+ }
174
+
175
+ // Update the authenticated user's metadata.
176
+ // Full replace — spread the current value if you only want to change one field:
177
+ // const me = await client.auth.me();
178
+ // await client.auth.updateMe({ ...me.metadata, bio: "Updated." });
179
+ async updateMe(metadata: Record<string, unknown>): Promise<AuthUser> {
180
+ const user = await this.http.request<AuthUser>("PATCH", "/api/v1/auth/me", {
181
+ body: { metadata },
182
+ });
183
+ this.cachedUser = user;
184
+ this.patchSnapshot({ user });
185
+ return user;
186
+ }
187
+
188
+ // Permanently delete the authenticated account (requires password confirmation).
189
+ async deleteAccount(password: string): Promise<void> {
190
+ await this.http.request<{ ok: boolean }>("DELETE", "/api/v1/auth/me", {
191
+ body: { password },
192
+ });
193
+ this.cachedUser = null;
194
+ this.patchSnapshot({ user: null });
195
+ this.http.clearTokens();
196
+ }
197
+
198
+ // Send a password-reset email. Always resolves successfully (server never reveals
199
+ // whether the address is registered).
200
+ async forgotPassword(email: string): Promise<void> {
201
+ await this.http.request<{ ok: boolean }>("POST", "/api/v1/auth/forgot-password", {
202
+ body: { email },
203
+ skipAuth: true,
204
+ });
205
+ }
206
+
207
+ // Complete the password-reset flow using the token from the email link.
208
+ async resetPassword(token: string, password: string): Promise<void> {
209
+ await this.http.request<{ ok: boolean }>("POST", "/api/v1/auth/reset-password", {
210
+ body: { token, password },
211
+ skipAuth: true,
212
+ });
213
+ }
214
+
215
+ // Verify an email address using the token from the verification link.
216
+ async verifyEmail(token: string): Promise<void> {
217
+ await this.http.request<{ ok: boolean }>("POST", "/api/v1/auth/verify-email", {
218
+ body: { token },
219
+ skipAuth: true,
220
+ });
221
+ }
222
+
223
+ // Re-send the verification email to the currently authenticated user.
224
+ async resendVerification(): Promise<void> {
225
+ await this.http.request<{ ok: boolean }>("POST", "/api/v1/auth/resend-verification");
226
+ }
227
+
228
+ // Returns true if an access token is currently held (does not validate expiry).
229
+ isAuthenticated(): boolean {
230
+ return this.http.getAccessToken() !== null;
231
+ }
232
+
233
+ // Restore a previously persisted session (e.g. from localStorage or Keychain).
234
+ // Pass the stored user object to pre-populate getCachedUser() so that useAuth()
235
+ // can initialize synchronously without a network round-trip.
236
+ restoreSession(accessToken: string, refreshToken: string, user?: AuthUser): void {
237
+ if (user) {
238
+ this.cachedUser = user;
239
+ this.patchSnapshot({ user });
240
+ }
241
+ this.http.setTokens(accessToken, refreshToken);
242
+ }
243
+
244
+ // ─── TOTP / 2FA management ───────────────────────────────────────────────────
245
+
246
+ // Get the current 2FA status for the authenticated user.
247
+ async getTotpStatus(): Promise<{ enabled: boolean }> {
248
+ return this.http.request<{ enabled: boolean }>("GET", "/api/v1/auth/2fa/status");
249
+ }
250
+
251
+ // Generate (or regenerate) a TOTP secret. Display the secret or otpauth_url
252
+ // to the user, then call enableTotp() with a code from their app.
253
+ async setupTotp(): Promise<TotpSetup> {
254
+ return this.http.request<TotpSetup>("POST", "/api/v1/auth/2fa/setup");
255
+ }
256
+
257
+ // Confirm TOTP setup and enable 2FA. code must be the current 6-digit code
258
+ // from the authenticator app loaded with the secret from setupTotp().
259
+ async enableTotp(code: string): Promise<void> {
260
+ await this.http.request<{ ok: boolean }>("POST", "/api/v1/auth/2fa/enable", {
261
+ body: { code },
262
+ });
263
+ }
264
+
265
+ // Disable 2FA. Requires a valid TOTP code to confirm the user has access.
266
+ async disableTotp(code: string): Promise<void> {
267
+ await this.http.request<{ ok: boolean }>("DELETE", "/api/v1/auth/2fa/disable", {
268
+ body: { code },
269
+ });
270
+ }
271
+
272
+ // ─── API keys ────────────────────────────────────────────────────────────────
273
+
274
+ // List the current user's API keys (key hashes are never returned).
275
+ async listApiKeys(): Promise<ApiKey[]> {
276
+ const result = await this.http.request<{ items: ApiKey[]; total: number }>(
277
+ "GET",
278
+ "/api/v1/auth/api-keys",
279
+ );
280
+ return result.items;
281
+ }
282
+
283
+ // Create a new API key. The raw key (bb_...) is returned once — store it immediately.
284
+ async createApiKey(name: string): Promise<ApiKey & { key: string }> {
285
+ return this.http.request<ApiKey & { key: string }>("POST", "/api/v1/auth/api-keys", {
286
+ body: { name },
287
+ });
288
+ }
289
+
290
+ // Revoke an API key by ID.
291
+ async revokeApiKey(id: string): Promise<void> {
292
+ await this.http.request<{ ok: boolean }>("DELETE", `/api/v1/auth/api-keys/${id}`);
293
+ }
294
+
295
+ // ─── Auth change listener ─────────────────────────────────────────────────
296
+
297
+ // Register a listener that fires when tokens change (login, refresh, logout).
298
+ // Returns an unsubscribe function.
299
+ onAuthChange(
300
+ listener: (session: { accessToken: string; refreshToken: string } | null) => void,
301
+ ): () => void {
302
+ return this.http.onTokenChange(({ accessToken, refreshToken }) => {
303
+ if (accessToken && refreshToken) {
304
+ listener({ accessToken, refreshToken });
305
+ } else {
306
+ listener(null);
307
+ }
308
+ });
309
+ }
310
+
311
+ // Periodically validate the active session so admin revocations are detected.
312
+ //
313
+ // FALLBACK: If the app uses RealtimeClient, auth events (session_revoked,
314
+ // account_deleted, sessions_purged, password_changed) are pushed instantly
315
+ // over the WebSocket and handled automatically by RealtimeClient — no polling
316
+ // needed. startSessionWatch is a fallback for environments without a WebSocket
317
+ // connection (e.g. server-side rendering, API-only clients).
318
+ //
319
+ // Calls me() on the given interval (default 30 s). When the session is no
320
+ // longer valid (access token expires and refresh is rejected because the
321
+ // session was revoked), HttpClient.clearTokens() fires automatically, which
322
+ // triggers onAuthChange(null) for all registered listeners.
323
+ //
324
+ // Returns a cleanup function — call it when the user signs out or the app
325
+ // unmounts to cancel the interval.
326
+ startSessionWatch(intervalMs = 30_000): () => void {
327
+ const id = setInterval(async () => {
328
+ if (!this.isAuthenticated()) return;
329
+ try {
330
+ await this.me();
331
+ } catch {
332
+ // If the session was revoked, refresh fails → clearTokens() is called
333
+ // by HttpClient → onAuthChange(null) fires. Nothing extra needed here.
334
+ }
335
+ }, intervalMs);
336
+ return () => clearInterval(id);
337
+ }
338
+ }
package/src/client.ts ADDED
@@ -0,0 +1,61 @@
1
+ // BunBaseClient — main entry point for the SDK.
2
+ //
3
+ // Usage:
4
+ // const client = new BunBaseClient({ url: "https://my.bunbase.server" });
5
+ // await client.auth.login({ email, password });
6
+ // const posts = client.collection<Post>("posts");
7
+ // const list = await posts.list({ sort: "-_created_at", limit: 20 });
8
+ // const unsub = client.realtime.subscribe("collection:posts", handler);
9
+
10
+ import { AdminClient } from "./admin";
11
+ import { AuthClient } from "./auth";
12
+ import { CollectionClient } from "./collection";
13
+ import { HttpClient } from "./http";
14
+ import { RealtimeClient } from "./realtime";
15
+ import { StorageClient } from "./storage";
16
+ import type { BunBaseClientOptions } from "./types";
17
+
18
+ export class BunBaseClient {
19
+ readonly auth: AuthClient;
20
+ readonly storage: StorageClient;
21
+ readonly realtime: RealtimeClient;
22
+ readonly admin: AdminClient;
23
+
24
+ private readonly http: HttpClient;
25
+
26
+ constructor(options: BunBaseClientOptions) {
27
+ const url = options.url.replace(/\/$/, ""); // strip trailing slash
28
+
29
+ // WebSocket URL: replace http(s) scheme with ws(s).
30
+ const wsUrl = url.replace(/^http/, "ws");
31
+
32
+ this.http = new HttpClient(url, options.apiKey, options.adminSecret);
33
+ this.auth = new AuthClient(this.http);
34
+ this.storage = new StorageClient(this.http);
35
+ this.realtime = new RealtimeClient(wsUrl, this.http);
36
+ this.admin = new AdminClient(this.http);
37
+ }
38
+
39
+ get baseUrl(): string {
40
+ return this.http.baseUrl;
41
+ }
42
+
43
+ // Update the admin secret (and optionally the server URL) without
44
+ // recreating the client. Call this after login or to clear on logout.
45
+ configure(opts: { url?: string; adminSecret?: string | null }): void {
46
+ if (opts.url !== undefined) {
47
+ this.http.baseUrl = opts.url.replace(/\/$/, "");
48
+ this.realtime.setWsUrl(this.http.baseUrl.replace(/^http/, "ws"));
49
+ }
50
+ if (opts.adminSecret !== undefined) {
51
+ this.http.setAdminSecret(opts.adminSecret);
52
+ }
53
+ }
54
+
55
+ // Returns a typed collection client for the given collection name.
56
+ collection<T extends Record<string, unknown> = Record<string, unknown>>(
57
+ name: string,
58
+ ): CollectionClient<T> {
59
+ return new CollectionClient<T>(this.http, name);
60
+ }
61
+ }
@@ -0,0 +1,185 @@
1
+ // CollectionClient — typed CRUD and list operations for a single collection.
2
+ //
3
+ // Usage:
4
+ // const posts = client.collection<Post>("posts");
5
+ // const result = await posts.list({ sort: "-_created_at", limit: 20 });
6
+ // const post = await posts.create({ title: "Hello" });
7
+
8
+ import type { HttpClient } from "./http";
9
+ import type {
10
+ AggregateFunction,
11
+ AggregateResult,
12
+ BatchOperation,
13
+ BatchResult,
14
+ BunBaseRecord,
15
+ Filter,
16
+ GetQuery,
17
+ ListQuery,
18
+ ListResult,
19
+ WithExpand,
20
+ } from "./types";
21
+
22
+ export class CollectionClient<T extends Record<string, unknown> = Record<string, unknown>> {
23
+ constructor(
24
+ private readonly http: HttpClient,
25
+ private readonly name: string,
26
+ ) {}
27
+
28
+ // ─── CRUD ──────────────────────────────────────────────────────────────────
29
+
30
+ async list<TExpand extends Record<string, unknown> = Record<string, never>>(
31
+ query: ListQuery<T> = {},
32
+ ): Promise<ListResult<WithExpand<T & BunBaseRecord, TExpand>>> {
33
+ const qs = buildQueryString(query);
34
+ return this.http.request<ListResult<WithExpand<T & BunBaseRecord, TExpand>>>(
35
+ "GET",
36
+ `/api/v1/${this.name}`,
37
+ { query: qs },
38
+ );
39
+ }
40
+
41
+ async get<TExpand extends Record<string, unknown> = Record<string, never>>(
42
+ id: string,
43
+ options: GetQuery = {},
44
+ ): Promise<WithExpand<T & BunBaseRecord, TExpand>> {
45
+ const qs: Record<string, string> = {};
46
+ if (options.expand?.length) qs.expand = options.expand.join(",");
47
+ return this.http.request<WithExpand<T & BunBaseRecord, TExpand>>(
48
+ "GET",
49
+ `/api/v1/${this.name}/${id}`,
50
+ { query: Object.keys(qs).length > 0 ? qs : undefined },
51
+ );
52
+ }
53
+
54
+ async create(data: Partial<T>): Promise<T & BunBaseRecord> {
55
+ return this.http.request<T & BunBaseRecord>("POST", `/api/v1/${this.name}`, { body: data });
56
+ }
57
+
58
+ // Create multiple records in a single request backed by a SQL transaction.
59
+ // All records are inserted atomically — if any insert fails, none are saved.
60
+ async createMany(items: Partial<T>[]): Promise<(T & BunBaseRecord)[]> {
61
+ if (items.length === 0) return [];
62
+ return this.http.request<(T & BunBaseRecord)[]>("POST", `/api/v1/${this.name}/bulk`, {
63
+ body: items,
64
+ });
65
+ }
66
+
67
+ async update(id: string, patch: Partial<T>): Promise<T & BunBaseRecord> {
68
+ return this.http.request<T & BunBaseRecord>("PATCH", `/api/v1/${this.name}/${id}`, {
69
+ body: patch,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Create or update a record by matching on one or more fields.
75
+ * Returns 201 on create, 200 on update, 409 if multiple records match.
76
+ */
77
+ async upsert(data: Partial<T>, match: Record<string, unknown>): Promise<T & BunBaseRecord> {
78
+ return this.http.request<T & BunBaseRecord>("POST", `/api/v1/${this.name}/upsert`, {
79
+ body: { data, match },
80
+ });
81
+ }
82
+
83
+ async delete(id: string): Promise<void> {
84
+ await this.http.request<{ deleted: boolean }>("DELETE", `/api/v1/${this.name}/${id}`);
85
+ }
86
+
87
+ // Restore a soft-deleted record.
88
+ async restore(id: string): Promise<T & BunBaseRecord> {
89
+ return this.http.request<T & BunBaseRecord>("POST", `/api/v1/${this.name}/${id}/restore`);
90
+ }
91
+
92
+ // Compute an aggregate (sum, avg, min, max, count) over the collection.
93
+ async aggregate(
94
+ fn: AggregateFunction,
95
+ options: {
96
+ field?: string;
97
+ group_by?: string;
98
+ filter?: Filter<T>;
99
+ include_deleted?: boolean;
100
+ } = {},
101
+ ): Promise<AggregateResult> {
102
+ const qs: Record<string, string> = { fn };
103
+ if (options.field) qs.field = options.field;
104
+ if (options.group_by) qs.group_by = options.group_by;
105
+ if (options.include_deleted) qs.include_deleted = "true";
106
+ if (options.filter) {
107
+ const filterQs = buildQueryString({ filter: options.filter });
108
+ Object.assign(qs, filterQs);
109
+ }
110
+ return this.http.request<AggregateResult>("GET", `/api/v1/${this.name}/aggregate`, {
111
+ query: qs,
112
+ });
113
+ }
114
+
115
+ // Count records matching the query filters.
116
+ async count(query: Pick<ListQuery<T>, "filter" | "include_deleted"> = {}): Promise<number> {
117
+ const qs = buildQueryString(query);
118
+ const result = await this.http.request<{ total: number }>("GET", `/api/v1/${this.name}/count`, {
119
+ query: Object.keys(qs).length > 0 ? qs : undefined,
120
+ });
121
+ return result.total;
122
+ }
123
+
124
+ // ─── Batch operations ──────────────────────────────────────────────────────
125
+ //
126
+ // Run up to 100 create/update/delete operations atomically.
127
+ // All operations succeed or all are rolled back.
128
+ // Returns the results in the same order as the input operations.
129
+
130
+ async batch(operations: BatchOperation<T>[]): Promise<BatchResult<T>[]> {
131
+ const result = await this.http.request<{ results: BatchResult<T>[] }>(
132
+ "POST",
133
+ `/api/v1/${this.name}/batch`,
134
+ { body: { operations } },
135
+ );
136
+ return result.results;
137
+ }
138
+
139
+ // ─── Realtime shorthand ────────────────────────────────────────────────────
140
+
141
+ // Returns a channel string for use with RealtimeClient.subscribe().
142
+ get channel(): string {
143
+ return `collection:${this.name}`;
144
+ }
145
+
146
+ recordChannel(id: string): string {
147
+ return `record:${this.name}:${id}`;
148
+ }
149
+ }
150
+
151
+ // ─── Query string builder ──────────────────────────────────────────────────────
152
+
153
+ export function buildQueryString<T>(query: ListQuery<T>): Record<string, string> {
154
+ const params: Record<string, string> = {};
155
+
156
+ if (query.filter) {
157
+ for (const [field, value] of Object.entries(query.filter)) {
158
+ if (value === undefined || value === null) continue;
159
+
160
+ if (typeof value === "object" && !Array.isArray(value)) {
161
+ // Operator filter: { age: { gte: 18 } } → filter[age][gte]=18
162
+ for (const [op, opVal] of Object.entries(value as Filter)) {
163
+ if (opVal !== undefined && opVal !== null) {
164
+ params[`filter[${field}][${op}]`] = String(opVal);
165
+ }
166
+ }
167
+ } else {
168
+ // Equality filter: { status: "published" } → filter[status]=published
169
+ params[`filter[${field}]`] = Array.isArray(value) ? value.join(",") : String(value);
170
+ }
171
+ }
172
+ }
173
+
174
+ if (query.sort) params.sort = query.sort;
175
+ if (query.page !== undefined) params.page = String(query.page);
176
+ if (query.limit !== undefined) params.limit = String(query.limit);
177
+ if (query.fields?.length) params.fields = query.fields.join(",");
178
+ if (query.expand?.length) params.expand = query.expand.join(",");
179
+ if (query.after) params.after = query.after;
180
+ if (query.include_deleted) params.include_deleted = "true";
181
+ if (query.search) params.search = query.search;
182
+ if (query.count) params.count = "true";
183
+
184
+ return params;
185
+ }