@bunbase-ae/js 1.0.1-next.7.bc3dec2 → 1.2.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/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @bunbase-ae/js
2
+
3
+ TypeScript/JavaScript SDK for [BunBase](https://docs-bunbase.palmcode.ae).
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ bun add @bunbase-ae/js
9
+ # or
10
+ npm install @bunbase-ae/js
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { BunBaseClient } from "@bunbase-ae/js";
17
+
18
+ const client = new BunBaseClient({ url: "https://your-bunbase-instance.example.com" });
19
+ ```
20
+
21
+ ## API
22
+
23
+ ### `BunBaseClient`
24
+
25
+ The root client. All sub-clients are accessed from here.
26
+
27
+ ```ts
28
+ const client = new BunBaseClient({ url: "https://..." });
29
+
30
+ client.collection("posts") // CollectionClient
31
+ client.auth // AuthClient
32
+ client.realtime // RealtimeClient
33
+ client.storage // StorageClient
34
+ client.admin // AdminClient
35
+ ```
36
+
37
+ ### Collections
38
+
39
+ ```ts
40
+ const posts = client.collection("posts");
41
+
42
+ // List records
43
+ const { items, total } = await posts.list({ sort: "-_created_at", limit: 20 });
44
+
45
+ // Get one record
46
+ const post = await posts.get("record-id");
47
+
48
+ // Create
49
+ const created = await posts.create({ title: "Hello", body: "..." });
50
+
51
+ // Update
52
+ await posts.update("record-id", { title: "Updated" });
53
+
54
+ // Delete
55
+ await posts.delete("record-id");
56
+ ```
57
+
58
+ ### Auth
59
+
60
+ ```ts
61
+ // Register
62
+ await client.auth.register({ email: "user@example.com", password: "secret" });
63
+
64
+ // Login
65
+ await client.auth.login({ email: "user@example.com", password: "secret" });
66
+
67
+ // Logout
68
+ await client.auth.logout();
69
+
70
+ // Subscribe to token changes (login, refresh, logout)
71
+ // session is { accessToken, refreshToken } when authenticated, or null when logged out
72
+ client.auth.onAuthChange((session) => {
73
+ if (session) {
74
+ console.log(session.accessToken, session.refreshToken);
75
+ } else {
76
+ console.log("logged out");
77
+ }
78
+ });
79
+ ```
80
+
81
+ ### Realtime
82
+
83
+ ```ts
84
+ const unsub = client.realtime.subscribe("posts", (event) => {
85
+ console.log(event.type, event.record);
86
+ });
87
+
88
+ // Later:
89
+ unsub();
90
+ ```
91
+
92
+ ### Storage
93
+
94
+ ```ts
95
+ // Upload a file
96
+ const result = await client.storage.upload("avatars", file);
97
+
98
+ // Get a public URL
99
+ const url = client.storage.url("avatars", "filename.png");
100
+ ```
101
+
102
+ ## Documentation
103
+
104
+ Full API reference and guides: [https://docs-bunbase.palmcode.ae](https://docs-bunbase.palmcode.ae)
package/package.json CHANGED
@@ -1,11 +1,30 @@
1
1
  {
2
2
  "name": "@bunbase-ae/js",
3
- "version": "1.0.1-next.7.bc3dec2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "TypeScript/JavaScript SDK for BunBase",
6
- "homepage": "https://docs-bunbase.palmcode.ae/sdk/javascript",
6
+ "license": "UNLICENSED",
7
+ "homepage": "https://docs-bunbase.palmcode.ae",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/palmcode-ae/bunbase.git",
11
+ "directory": "sdk/js"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/palmcode-ae/bunbase/issues"
15
+ },
16
+ "keywords": [
17
+ "bunbase",
18
+ "sdk",
19
+ "typescript",
20
+ "database",
21
+ "realtime",
22
+ "auth",
23
+ "storage"
24
+ ],
7
25
  "files": [
8
- "src"
26
+ "src",
27
+ "README.md"
9
28
  ],
10
29
  "main": "./src/index.ts",
11
30
  "exports": {
package/src/admin.ts CHANGED
@@ -374,6 +374,14 @@ class AdminUsersClient {
374
374
  return res.items;
375
375
  }
376
376
 
377
+ async createApiKeyFor(userId: string, name: string): Promise<AdminApiKey & { key: string }> {
378
+ return this.http.request<AdminApiKey & { key: string }>(
379
+ "POST",
380
+ `/api/v1/admin/users/${userId}/api-keys`,
381
+ { body: { name } },
382
+ );
383
+ }
384
+
377
385
  async revokeApiKey(userId: string, keyId: string): Promise<void> {
378
386
  await this.http.request<{ ok: boolean }>(
379
387
  "DELETE",
@@ -520,11 +528,7 @@ class AdminCollectionsClient {
520
528
  if (opts.sort) params.sort = opts.sort;
521
529
  if (opts.includeDeleted) params.include_deleted = "true";
522
530
  if (opts.search) params.search = opts.search;
523
- if (opts.filter) {
524
- for (const [field, value] of Object.entries(opts.filter)) {
525
- params[`filter[${field}]`] = value;
526
- }
527
- }
531
+ if (opts.filter) Object.assign(params, opts.filter);
528
532
  return this.http.request<AdminListResult<AdminRecord>>(
529
533
  "GET",
530
534
  `/api/v1/admin/collections/${collection}/records`,
package/src/auth.ts CHANGED
@@ -4,7 +4,15 @@
4
4
  // handles the API calls and wires the results back into HttpClient.
5
5
 
6
6
  import type { HttpClient } from "./http";
7
- import type { ApiKey, AuthResult, AuthUser, LoginResult, TotpChallenge, TotpSetup } from "./types";
7
+ import type {
8
+ ApiKey,
9
+ AuthResult,
10
+ AuthUser,
11
+ LoginResult,
12
+ PersistSessionOptions,
13
+ TotpChallenge,
14
+ TotpSetup,
15
+ } from "./types";
8
16
 
9
17
  // Reactive snapshot of auth state. Consumed by useAuth() via useSyncExternalStore.
10
18
  export interface AuthSnapshot {
@@ -13,6 +21,10 @@ export interface AuthSnapshot {
13
21
  error: Error | null;
14
22
  }
15
23
 
24
+ // Represents the active session passed to onAuthChange listeners.
25
+ // Token-based sessions carry access/refresh tokens; API-key sessions carry the key.
26
+ export type AuthSession = { accessToken: string; refreshToken: string } | { apiKey: string };
27
+
16
28
  export class AuthClient {
17
29
  // Cached user — set on every successful login/register/me/verifyTotp call.
18
30
  // Cleared on logout. Lets useAuth() initialize synchronously without a network round-trip.
@@ -150,6 +162,7 @@ export class AuthClient {
150
162
  this.cachedUser = null;
151
163
  this.patchSnapshot({ user: null });
152
164
  this.http.clearTokens();
165
+ this.http.setApiKey(null);
153
166
  }
154
167
  }
155
168
 
@@ -162,6 +175,7 @@ export class AuthClient {
162
175
  this.cachedUser = null;
163
176
  this.patchSnapshot({ user: null });
164
177
  this.http.clearTokens();
178
+ this.http.setApiKey(null);
165
179
  return { sessions_revoked: result.sessions_revoked };
166
180
  }
167
181
 
@@ -225,9 +239,28 @@ export class AuthClient {
225
239
  await this.http.request<{ ok: boolean }>("POST", "/api/v1/auth/resend-verification");
226
240
  }
227
241
 
228
- // Returns true if an access token is currently held (does not validate expiry).
242
+ // Returns true if an access token or API key is currently held (does not validate expiry).
229
243
  isAuthenticated(): boolean {
230
- return this.http.getAccessToken() !== null;
244
+ return this.http.getAccessToken() !== null || this.http.getApiKey() !== null;
245
+ }
246
+
247
+ // Log in using a long-lived API key (e.g. for RFID / hardware-token login flows).
248
+ //
249
+ // Sets the API key on the HTTP layer, fetches /auth/me with it to verify
250
+ // the key is valid and retrieve the user, then updates the auth snapshot so
251
+ // that useAuth() and getCachedUser() reflect the authenticated state.
252
+ //
253
+ // On error (invalid or revoked key) the API key is cleared and the error is
254
+ // re-thrown. Call auth.logout() to sign out — it clears the API key too.
255
+ async loginWithApiKey(apiKey: string): Promise<AuthUser> {
256
+ this.http.setApiKey(apiKey);
257
+ try {
258
+ const user = await this.me(); // me() already updates cachedUser and snapshot
259
+ return user;
260
+ } catch (err) {
261
+ this.http.setApiKey(null);
262
+ throw err;
263
+ }
231
264
  }
232
265
 
233
266
  // Restore a previously persisted session (e.g. from localStorage or Keychain).
@@ -292,9 +325,12 @@ export class AuthClient {
292
325
  await this.http.request<{ ok: boolean }>("DELETE", `/api/v1/auth/api-keys/${id}`);
293
326
  }
294
327
 
295
- // ─── Auth change listener ─────────────────────────────────────────────────
328
+ // ─── Auth change listeners ────────────────────────────────────────────────
296
329
 
297
- // Register a listener that fires when tokens change (login, refresh, logout).
330
+ // Register a listener for token-based auth transitions: fires with
331
+ // { accessToken, refreshToken } on login/refresh, and null on logout.
332
+ // API-key sessions do not trigger this listener — use onApiKeyChange for those.
333
+ //
298
334
  // Returns an unsubscribe function.
299
335
  onAuthChange(
300
336
  listener: (session: { accessToken: string; refreshToken: string } | null) => void,
@@ -308,6 +344,81 @@ export class AuthClient {
308
344
  });
309
345
  }
310
346
 
347
+ // Register a listener for API-key auth transitions: fires with the key string
348
+ // on loginWithApiKey, and null when the key is cleared (e.g. after logout).
349
+ // Token-based sessions do not trigger this listener — use onAuthChange for those.
350
+ //
351
+ // Returns an unsubscribe function.
352
+ onApiKeyChange(listener: (apiKey: string | null) => void): () => void {
353
+ return this.http.onApiKeyChange(listener);
354
+ }
355
+
356
+ // Wire automatic session persistence against a StorageAdapter.
357
+ //
358
+ // Called by BunBaseClient when `persistSession` is set. Sets loading=true
359
+ // on the snapshot while the async read is in flight, then restores the
360
+ // session (if tokens are found) and sets loading=false. Also registers an
361
+ // onAuthChange listener that writes tokens to storage on login/refresh and
362
+ // clears them on logout.
363
+ //
364
+ // The method is intentionally synchronous — the async work is fire-and-forget
365
+ // so the constructor can call it without awaiting.
366
+ initPersistence(opts: PersistSessionOptions): void {
367
+ const { storage, prefix } = opts;
368
+ const atKey = `${prefix}_bb_at`;
369
+ const rtKey = `${prefix}_bb_rt`;
370
+ const userKey = `${prefix}_bb_user`;
371
+
372
+ this.patchSnapshot({ loading: true });
373
+
374
+ void (async () => {
375
+ try {
376
+ const [at, rt, userStr] = await Promise.all([
377
+ storage.getItem(atKey),
378
+ storage.getItem(rtKey),
379
+ storage.getItem(userKey),
380
+ ]);
381
+ if (at && rt) {
382
+ let user: AuthUser | undefined;
383
+ if (userStr) {
384
+ try {
385
+ user = JSON.parse(userStr) as AuthUser;
386
+ } catch {
387
+ // Corrupt cached user — ignore and proceed without it.
388
+ }
389
+ }
390
+ this.restoreSession(at, rt, user);
391
+ }
392
+ } finally {
393
+ this.patchSnapshot({ loading: false });
394
+ }
395
+ })();
396
+
397
+ // Wrap every storage call so that both synchronous throws (e.g. localStorage
398
+ // quota / security errors) and async rejections are absorbed silently and
399
+ // never propagate as unhandled rejections into the login/logout/refresh flow.
400
+ const safeWrite = (fn: () => unknown): void => {
401
+ try {
402
+ Promise.resolve(fn()).catch(() => {});
403
+ } catch {
404
+ // absorb synchronous throws
405
+ }
406
+ };
407
+
408
+ this.onAuthChange((session) => {
409
+ if (session) {
410
+ safeWrite(() => storage.setItem(atKey, session.accessToken));
411
+ safeWrite(() => storage.setItem(rtKey, session.refreshToken));
412
+ const user = this.getCachedUser();
413
+ if (user) safeWrite(() => storage.setItem(userKey, JSON.stringify(user)));
414
+ } else {
415
+ safeWrite(() => storage.removeItem(atKey));
416
+ safeWrite(() => storage.removeItem(rtKey));
417
+ safeWrite(() => storage.removeItem(userKey));
418
+ }
419
+ });
420
+ }
421
+
311
422
  // Periodically validate the active session so admin revocations are detected.
312
423
  //
313
424
  // FALLBACK: If the app uses RealtimeClient, auth events (session_revoked,
package/src/client.ts CHANGED
@@ -34,6 +34,10 @@ export class BunBaseClient {
34
34
  this.storage = new StorageClient(this.http);
35
35
  this.realtime = new RealtimeClient(wsUrl, this.http);
36
36
  this.admin = new AdminClient(this.http);
37
+
38
+ if (options.persistSession) {
39
+ this.auth.initPersistence(options.persistSession);
40
+ }
37
41
  }
38
42
 
39
43
  get baseUrl(): string {
package/src/http.ts CHANGED
@@ -15,22 +15,41 @@ interface TokenStore {
15
15
  }
16
16
 
17
17
  export type TokenChangeListener = (tokens: TokenStore) => void;
18
+ export type ApiKeyChangeListener = (apiKey: string | null) => void;
18
19
 
19
20
  export class HttpClient {
20
21
  private tokens: TokenStore = { accessToken: null, refreshToken: null };
21
22
  private refreshPromise: Promise<void> | null = null;
22
23
  private tokenListeners: TokenChangeListener[] = [];
24
+ private apiKeyListeners: ApiKeyChangeListener[] = [];
23
25
 
24
26
  baseUrl: string;
25
- private readonly apiKey?: string;
27
+ private _apiKey: string | null;
26
28
  private adminSecret: string | null;
27
29
 
28
30
  constructor(baseUrl: string, apiKey?: string, adminSecret?: string) {
29
31
  this.baseUrl = baseUrl;
30
- this.apiKey = apiKey;
32
+ this._apiKey = apiKey ?? null;
31
33
  this.adminSecret = adminSecret ?? null;
32
34
  }
33
35
 
36
+ setApiKey(key: string | null): void {
37
+ if (this._apiKey === key) return;
38
+ this._apiKey = key;
39
+ this.notifyApiKeyListeners();
40
+ }
41
+
42
+ getApiKey(): string | null {
43
+ return this._apiKey;
44
+ }
45
+
46
+ onApiKeyChange(listener: ApiKeyChangeListener): () => void {
47
+ this.apiKeyListeners.push(listener);
48
+ return () => {
49
+ this.apiKeyListeners = this.apiKeyListeners.filter((l) => l !== listener);
50
+ };
51
+ }
52
+
34
53
  setAdminSecret(secret: string | null): void {
35
54
  this.adminSecret = secret;
36
55
  }
@@ -66,11 +85,15 @@ export class HttpClient {
66
85
  for (const l of this.tokenListeners) l(this.tokens);
67
86
  }
68
87
 
88
+ private notifyApiKeyListeners(): void {
89
+ for (const l of this.apiKeyListeners) l(this._apiKey);
90
+ }
91
+
69
92
  // Returns the auth headers this client would attach to a request.
70
93
  // Used by StorageClient for XHR-based uploads with progress tracking.
71
94
  getAuthHeaders(): Record<string, string> {
72
95
  if (this.adminSecret) return { Authorization: `Bearer ${this.adminSecret}` };
73
- if (this.apiKey) return { "X-Api-Key": this.apiKey };
96
+ if (this._apiKey) return { "X-Api-Key": this._apiKey };
74
97
  if (this.tokens.accessToken) return { Authorization: `Bearer ${this.tokens.accessToken}` };
75
98
  return {};
76
99
  }
@@ -85,6 +108,8 @@ export class HttpClient {
85
108
  formData?: FormData;
86
109
  query?: Record<string, string>;
87
110
  skipAuth?: boolean;
111
+ signal?: AbortSignal;
112
+ keepalive?: boolean;
88
113
  } = {},
89
114
  ): Promise<T> {
90
115
  const res = await this.send(method, path, options);
@@ -94,7 +119,7 @@ export class HttpClient {
94
119
  res.status === 401 &&
95
120
  !options.skipAuth &&
96
121
  !this.adminSecret &&
97
- !this.apiKey &&
122
+ !this._apiKey &&
98
123
  this.tokens.refreshToken
99
124
  ) {
100
125
  try {
@@ -136,6 +161,8 @@ export class HttpClient {
136
161
  formData?: FormData;
137
162
  query?: Record<string, string>;
138
163
  skipAuth?: boolean;
164
+ signal?: AbortSignal;
165
+ keepalive?: boolean;
139
166
  },
140
167
  ): Promise<Response> {
141
168
  let url = `${this.baseUrl}${path}`;
@@ -148,8 +175,8 @@ export class HttpClient {
148
175
  if (!options.skipAuth) {
149
176
  if (this.adminSecret) {
150
177
  headers.Authorization = `Bearer ${this.adminSecret}`;
151
- } else if (this.apiKey) {
152
- headers["X-Api-Key"] = this.apiKey;
178
+ } else if (this._apiKey) {
179
+ headers["X-Api-Key"] = this._apiKey;
153
180
  } else if (this.tokens.accessToken) {
154
181
  headers.Authorization = `Bearer ${this.tokens.accessToken}`;
155
182
  }
@@ -164,7 +191,13 @@ export class HttpClient {
164
191
  body = JSON.stringify(options.body);
165
192
  }
166
193
 
167
- return fetch(url, { method, headers, body });
194
+ return fetch(url, {
195
+ method,
196
+ headers,
197
+ body,
198
+ signal: options.signal,
199
+ keepalive: options.keepalive,
200
+ });
168
201
  }
169
202
 
170
203
  private async parse<T>(res: Response): Promise<T> {
package/src/index.ts CHANGED
@@ -38,14 +38,7 @@ export { AuthClient, type AuthSnapshot } from "./auth";
38
38
  export { BunBaseClient } from "./client";
39
39
  export { CollectionClient } from "./collection";
40
40
  export { RealtimeClient, type SubscribeOptions } from "./realtime";
41
- export {
42
- type ImageTransformFit,
43
- type ImageTransformFormat,
44
- type ImageTransformOptions,
45
- type SignedUploadResult,
46
- StorageClient,
47
- type UploadOptions,
48
- } from "./storage";
41
+ export { type SignedUploadResult, StorageClient, type UploadOptions } from "./storage";
49
42
  export {
50
43
  type AggregateFunction,
51
44
  type AggregateResult,
@@ -73,9 +66,11 @@ export {
73
66
  type ListQuery,
74
67
  type ListResult,
75
68
  type LoginResult,
69
+ type PersistSessionOptions,
76
70
  type RealtimeCallback,
77
71
  type RealtimeEvent,
78
72
  type RealtimeEventType,
73
+ type StorageAdapter,
79
74
  type TotpChallenge,
80
75
  type TotpSetup,
81
76
  type UnsubscribeFn,
package/src/storage.ts CHANGED
@@ -4,22 +4,6 @@ import type { HttpClient } from "./http";
4
4
  import type { FileRecord } from "./types";
5
5
  import { BunBaseError } from "./types";
6
6
 
7
- export type ImageTransformFit = "cover" | "contain" | "inside";
8
- export type ImageTransformFormat = "webp" | "jpeg" | "png";
9
-
10
- export interface ImageTransformOptions {
11
- /** Output width in pixels (1–4096). */
12
- width?: number;
13
- /** Output height in pixels (1–4096). */
14
- height?: number;
15
- /** How to fit the image into the given dimensions. Default: "inside". */
16
- fit?: ImageTransformFit;
17
- /** Convert to this output format. Omit to keep the original format. */
18
- format?: ImageTransformFormat;
19
- /** Quality for JPEG/WebP, 1–100. Default: 85. */
20
- quality?: number;
21
- }
22
-
23
7
  export interface UploadOptions {
24
8
  // Target storage bucket. Defaults to "default" if omitted.
25
9
  bucket?: string;
@@ -206,21 +190,10 @@ export class StorageClient {
206
190
  // Do NOT use this for private files — the browser cannot send the
207
191
  // Authorization header via <img src>. For private files, call signedUrl()
208
192
  // to get a time-limited signed URL instead.
209
- downloadUrl(id: string, filename?: string | null, transform?: ImageTransformOptions): string {
193
+ downloadUrl(id: string, filename?: string | null): string {
210
194
  if (id.startsWith("http")) return id;
211
195
  const base = `${this.http.baseUrl}/api/v1/storage/${encodeURIComponent(id)}`;
212
- let url = filename ? `${base}/${encodeURIComponent(filename)}` : base;
213
- if (transform) {
214
- const params = new URLSearchParams();
215
- if (transform.width != null) params.set("w", String(transform.width));
216
- if (transform.height != null) params.set("h", String(transform.height));
217
- if (transform.fit) params.set("fit", transform.fit);
218
- if (transform.format) params.set("format", transform.format);
219
- if (transform.quality != null) params.set("q", String(transform.quality));
220
- const qs = params.toString();
221
- if (qs) url = `${url}?${qs}`;
222
- }
223
- return url;
196
+ return filename ? `${base}/${encodeURIComponent(filename)}` : base;
224
197
  }
225
198
 
226
199
  async list(): Promise<FileRecord[]> {
package/src/types.ts CHANGED
@@ -237,6 +237,25 @@ export interface ApiKey {
237
237
  created_at: number;
238
238
  }
239
239
 
240
+ // Minimal storage interface — deliberately async-compatible so the same API
241
+ // works with synchronous Web Storage (localStorage, sessionStorage) and
242
+ // Promise-based stores such as React Native's AsyncStorage or any custom
243
+ // adapter (SecureStore, IndexedDB wrappers, etc.).
244
+ export interface StorageAdapter {
245
+ getItem(key: string): string | null | Promise<string | null>;
246
+ setItem(key: string, value: string): void | Promise<void>;
247
+ removeItem(key: string): void | Promise<void>;
248
+ }
249
+
250
+ export interface PersistSessionOptions {
251
+ // Storage backend. Pass `localStorage`, `sessionStorage`, AsyncStorage, or
252
+ // any object that satisfies the StorageAdapter interface.
253
+ storage: StorageAdapter;
254
+ // Key prefix — isolates tokens per app so concurrent tabs don't clobber
255
+ // each other. Keys written: `{prefix}_bb_at`, `{prefix}_bb_rt`, `{prefix}_bb_user`.
256
+ prefix: string;
257
+ }
258
+
240
259
  export interface BunBaseClientOptions {
241
260
  // Base URL of the BunBase server — no trailing slash.
242
261
  url: string;
@@ -248,6 +267,10 @@ export interface BunBaseClientOptions {
248
267
  // the token refresh flow. Use for server-to-server admin calls or Studio.
249
268
  // Alternatively, authenticate as a user with the "admin" role via login().
250
269
  adminSecret?: string;
270
+ // When set, the SDK automatically restores the session on construction and
271
+ // persists it on every auth change (login, refresh, logout). The loading
272
+ // field on AuthSnapshot is true while the async restore is in flight.
273
+ persistSession?: PersistSessionOptions;
251
274
  }
252
275
 
253
276
  export class BunBaseError extends Error {