@picobase_app/client 0.5.2 → 1.0.2

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/index.mjs CHANGED
@@ -1,130 +1,462 @@
1
- import PocketBase from 'pocketbase';
1
+ // src/errors.ts
2
+ var PicoBaseError = class extends Error {
3
+ constructor(message, code, status, details, fix) {
4
+ super(message);
5
+ this.code = code;
6
+ this.status = status;
7
+ this.details = details;
8
+ this.fix = fix;
9
+ this.name = "PicoBaseError";
10
+ }
11
+ /** Formatted error string including fix suggestion. */
12
+ toString() {
13
+ let s = `${this.name} [${this.code}]: ${this.message}`;
14
+ if (this.fix) s += `
15
+ Fix: ${this.fix}`;
16
+ return s;
17
+ }
18
+ };
19
+ var InstanceUnavailableError = class extends PicoBaseError {
20
+ constructor(message = "Instance is not available. It may be stopped or starting up.") {
21
+ super(
22
+ message,
23
+ "INSTANCE_UNAVAILABLE",
24
+ 503,
25
+ void 0,
26
+ "Check your instance status in the PicoBase dashboard, or wait a few seconds and retry. If this persists, your instance may have been stopped \u2014 restart it with `picobase status`."
27
+ );
28
+ this.name = "InstanceUnavailableError";
29
+ }
30
+ };
31
+ var AuthorizationError = class extends PicoBaseError {
32
+ constructor(message = "Invalid or missing API key.") {
33
+ super(
34
+ message,
35
+ "UNAUTHORIZED",
36
+ 401,
37
+ void 0,
38
+ 'Make sure PICOBASE_API_KEY is set in your .env file and matches a valid key from your dashboard. Keys start with "pbk_". You can generate a new key at https://picobase.com/dashboard.'
39
+ );
40
+ this.name = "AuthorizationError";
41
+ }
42
+ };
43
+ var CollectionNotFoundError = class extends PicoBaseError {
44
+ constructor(collectionName) {
45
+ super(
46
+ `Collection "${collectionName}" not found.`,
47
+ "COLLECTION_NOT_FOUND",
48
+ 404,
49
+ { collection: collectionName },
50
+ `Make sure the collection "${collectionName}" exists in your PicoBase instance. Collections are auto-created when you first write data, or you can create them manually in the PicoBase dashboard under Collections.`
51
+ );
52
+ this.name = "CollectionNotFoundError";
53
+ }
54
+ };
55
+ var RecordNotFoundError = class extends PicoBaseError {
56
+ constructor(collectionName, recordId) {
57
+ super(
58
+ `Record "${recordId}" not found in collection "${collectionName}".`,
59
+ "RECORD_NOT_FOUND",
60
+ 404,
61
+ { collection: collectionName, recordId },
62
+ 'Check that the record ID is correct. IDs are 15-character alphanumeric strings (e.g., "abc123def456789").'
63
+ );
64
+ this.name = "RecordNotFoundError";
65
+ }
66
+ };
67
+ var RequestError = class extends PicoBaseError {
68
+ constructor(message, status, details) {
69
+ const fix = requestErrorFix(status, message);
70
+ super(message, "REQUEST_FAILED", status, details, fix);
71
+ this.name = "RequestError";
72
+ }
73
+ };
74
+ var ConfigurationError = class extends PicoBaseError {
75
+ constructor(message, fix) {
76
+ super(message, "CONFIGURATION_ERROR", void 0, void 0, fix);
77
+ this.name = "ConfigurationError";
78
+ }
79
+ };
80
+ var RpcError = class extends PicoBaseError {
81
+ constructor(functionName, status, details) {
82
+ const fix = rpcErrorFix(functionName, status);
83
+ super(
84
+ `RPC function "${functionName}" failed.`,
85
+ "RPC_ERROR",
86
+ status,
87
+ details,
88
+ fix
89
+ );
90
+ this.name = "RpcError";
91
+ }
92
+ };
93
+ function rpcErrorFix(functionName, status) {
94
+ if (status === 404) {
95
+ return `The RPC endpoint "/api/rpc/${functionName}" does not exist. Create a custom route in your PocketBase instance to handle this RPC call. See: https://pocketbase.io/docs/js-routing/`;
96
+ }
97
+ if (status === 400) {
98
+ return "Check the parameters you are passing to this RPC function. The function may be expecting different parameters or types.";
99
+ }
100
+ if (status === 403) {
101
+ return "You don't have permission to call this RPC function. Check the authentication requirements for this endpoint in your PocketBase routes.";
102
+ }
103
+ return "Check your PicoBase instance logs for details about this RPC error. Ensure the custom route is correctly implemented in your PocketBase setup.";
104
+ }
105
+ function requestErrorFix(status, message) {
106
+ switch (status) {
107
+ case 400:
108
+ return "Check the data you are sending \u2014 a required field may be missing or have the wrong type. Run `picobase typegen` to regenerate types and check your field names.";
109
+ case 403:
110
+ return "You don't have permission for this action. Check your collection API rules in the dashboard. By default, only authenticated users can read/write records.";
111
+ case 404:
112
+ if (message.toLowerCase().includes("collection"))
113
+ return "This collection does not exist yet. Write a record to auto-create it, or create it in the dashboard.";
114
+ return "The requested resource was not found. Double-check IDs and collection names.";
115
+ case 413:
116
+ return "The request payload is too large. Check file upload size limits in your instance settings.";
117
+ case 429:
118
+ return "Too many requests. Add a short delay between requests or implement client-side caching.";
119
+ default:
120
+ return "If this error persists, check your PicoBase dashboard for instance health and logs.";
121
+ }
122
+ }
2
123
 
3
- // src/client.ts
124
+ // src/http.ts
125
+ var PicoBaseHttpClient = class {
126
+ constructor(baseUrl, apiKey, options = {}) {
127
+ this.baseUrl = baseUrl;
128
+ this.apiKey = apiKey;
129
+ this.timeout = options.timeout ?? 3e4;
130
+ this.maxColdStartRetries = options.maxColdStartRetries ?? 3;
131
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
132
+ this.getToken = options.getToken;
133
+ }
134
+ async send(path, init = {}) {
135
+ const { query, body: rawBody, ...restInit } = init;
136
+ let url = `${this.baseUrl}${path}`;
137
+ if (query) {
138
+ const params = new URLSearchParams();
139
+ for (const [k, v] of Object.entries(query)) {
140
+ if (v !== void 0 && v !== null) params.append(k, String(v));
141
+ }
142
+ const qs = params.toString();
143
+ if (qs) url += `?${qs}`;
144
+ }
145
+ const headers = {
146
+ ...restInit.headers ?? {}
147
+ };
148
+ headers["X-PicoBase-Key"] = this.apiKey;
149
+ const token = this.getToken?.();
150
+ if (token) headers["Authorization"] = `Bearer ${token}`;
151
+ let body;
152
+ if (rawBody instanceof FormData) {
153
+ body = rawBody;
154
+ } else if (rawBody !== void 0 && rawBody !== null) {
155
+ headers["Content-Type"] = "application/json";
156
+ body = typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody);
157
+ }
158
+ let lastError = new Error("Unknown error");
159
+ for (let attempt = 0; attempt <= this.maxColdStartRetries; attempt++) {
160
+ const controller = new AbortController();
161
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
162
+ let response;
163
+ try {
164
+ response = await this.fetchImpl(url, {
165
+ ...restInit,
166
+ headers,
167
+ body,
168
+ signal: controller.signal
169
+ });
170
+ } catch (fetchErr) {
171
+ clearTimeout(timeoutId);
172
+ const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
173
+ throw new RequestError(msg, 0, fetchErr);
174
+ }
175
+ clearTimeout(timeoutId);
176
+ if (response.ok) {
177
+ if (response.status === 204) return void 0;
178
+ return await response.json();
179
+ }
180
+ let errorData = null;
181
+ try {
182
+ errorData = await response.json();
183
+ } catch {
184
+ }
185
+ const message = errorData?.message ?? errorData?.error ?? response.statusText;
186
+ const status = response.status;
187
+ if (status === 503) {
188
+ lastError = new RequestError(message, status, errorData);
189
+ if (attempt < this.maxColdStartRetries) {
190
+ const delay = Math.pow(2, attempt + 1) * 1e3;
191
+ await new Promise((resolve) => setTimeout(resolve, delay));
192
+ continue;
193
+ }
194
+ break;
195
+ }
196
+ if (status === 401) {
197
+ const code = errorData?.code;
198
+ if (!code || code === "INVALID_API_KEY") throw new AuthorizationError();
199
+ throw new AuthorizationError(message);
200
+ }
201
+ if (status === 404) {
202
+ const lower = message.toLowerCase();
203
+ if (lower.includes("collection") && (lower.includes("not found") || lower.includes("missing"))) {
204
+ const match = message.match(/["']([^"']+)["']/);
205
+ throw new CollectionNotFoundError(match?.[1] ?? "unknown");
206
+ }
207
+ }
208
+ throw new RequestError(message, status, errorData);
209
+ }
210
+ throw new InstanceUnavailableError(
211
+ `Instance unavailable after ${this.maxColdStartRetries} retries. Original error: ${lastError.message}`
212
+ );
213
+ }
214
+ };
215
+
216
+ // src/auth-store.ts
217
+ var STORAGE_KEY_TOKEN = "picobase_token";
218
+ var STORAGE_KEY_RECORD = "picobase_record";
219
+ var AuthStore = class {
220
+ constructor() {
221
+ this._token = "";
222
+ this._record = null;
223
+ this._listeners = /* @__PURE__ */ new Set();
224
+ this._loadFromStorage();
225
+ }
226
+ /** Current JWT, or empty string if not signed in. */
227
+ get token() {
228
+ return this._token;
229
+ }
230
+ /** Currently signed-in user record, or null. */
231
+ get record() {
232
+ return this._record;
233
+ }
234
+ /**
235
+ * Returns true if a token is present and not yet expired.
236
+ * Checks JWT `exp` claim — no signature verification (the server does that).
237
+ */
238
+ get isValid() {
239
+ if (!this._token) return false;
240
+ try {
241
+ const parts = this._token.split(".");
242
+ if (parts.length !== 3) return false;
243
+ const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
244
+ const json = typeof atob !== "undefined" ? atob(padded) : Buffer.from(padded, "base64").toString("utf8");
245
+ const payload = JSON.parse(json);
246
+ return typeof payload.exp === "number" && payload.exp * 1e3 > Date.now();
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+ /** Save a token (and optionally the user record). Persists to localStorage. */
252
+ save(token, record = null) {
253
+ this._token = token;
254
+ this._record = record;
255
+ try {
256
+ if (typeof localStorage !== "undefined") {
257
+ localStorage.setItem(STORAGE_KEY_TOKEN, token);
258
+ if (record) {
259
+ localStorage.setItem(STORAGE_KEY_RECORD, JSON.stringify(record));
260
+ } else {
261
+ localStorage.removeItem(STORAGE_KEY_RECORD);
262
+ }
263
+ }
264
+ } catch {
265
+ }
266
+ this._notify();
267
+ }
268
+ /** Clear the token and user record. Removes from localStorage. */
269
+ clear() {
270
+ this._token = "";
271
+ this._record = null;
272
+ try {
273
+ if (typeof localStorage !== "undefined") {
274
+ localStorage.removeItem(STORAGE_KEY_TOKEN);
275
+ localStorage.removeItem(STORAGE_KEY_RECORD);
276
+ }
277
+ } catch {
278
+ }
279
+ this._notify();
280
+ }
281
+ /**
282
+ * Register a listener for auth state changes.
283
+ * @returns Unsubscribe function.
284
+ */
285
+ onChange(callback) {
286
+ this._listeners.add(callback);
287
+ return () => {
288
+ this._listeners.delete(callback);
289
+ };
290
+ }
291
+ // ── Private ───────────────────────────────────────────────────────────────
292
+ _loadFromStorage() {
293
+ try {
294
+ if (typeof localStorage !== "undefined") {
295
+ const token = localStorage.getItem(STORAGE_KEY_TOKEN);
296
+ if (token) {
297
+ this._token = token;
298
+ const raw = localStorage.getItem(STORAGE_KEY_RECORD);
299
+ if (raw) {
300
+ try {
301
+ this._record = JSON.parse(raw);
302
+ } catch {
303
+ }
304
+ }
305
+ }
306
+ }
307
+ } catch {
308
+ }
309
+ }
310
+ _notify() {
311
+ for (const cb of this._listeners) {
312
+ try {
313
+ cb(this._token, this._record);
314
+ } catch {
315
+ }
316
+ }
317
+ }
318
+ };
4
319
 
5
320
  // src/auth.ts
6
321
  var PicoBaseAuth = class {
7
- constructor(pb) {
322
+ constructor(http, authStore) {
8
323
  this.listeners = /* @__PURE__ */ new Set();
9
- this._collection = "users";
10
- this.pb = pb;
11
- this.pb.authStore.onChange((token) => {
12
- const record = this.pb.authStore.record ?? null;
324
+ this._collection = "_auth_users";
325
+ this.http = http;
326
+ this.authStore = authStore;
327
+ this.authStore.onChange((token) => {
328
+ const record = this.authStore.record;
13
329
  const event = token ? "SIGNED_IN" : "SIGNED_OUT";
14
330
  this._notify(event, record);
15
331
  });
16
332
  }
17
333
  /**
18
334
  * Set which collection to authenticate against.
19
- * Defaults to 'users'. Use this if you have a custom auth collection.
335
+ * Defaults to '_auth_users'. Use this if you have a custom auth collection.
20
336
  */
21
337
  setCollection(name) {
22
338
  this._collection = name;
23
339
  return this;
24
340
  }
25
341
  /**
26
- * Create a new user account.
342
+ * Create a new user account. Automatically signs the user in after creation.
27
343
  */
28
344
  async signUp(options) {
29
345
  const { email, password, passwordConfirm, ...rest } = options;
30
- await this.pb.collection(this._collection).create({
31
- email,
32
- password,
33
- passwordConfirm: passwordConfirm ?? password,
34
- ...rest
346
+ await this.http.send("/api/db/collections/_auth_users/records", {
347
+ method: "POST",
348
+ body: {
349
+ email,
350
+ password,
351
+ passwordConfirm: passwordConfirm ?? password,
352
+ ...rest
353
+ }
35
354
  });
36
- const authResult = await this.pb.collection(this._collection).authWithPassword(email, password);
37
- return {
38
- token: authResult.token,
39
- record: authResult.record
40
- };
355
+ return this.signIn({ email, password });
41
356
  }
42
357
  /**
43
358
  * Sign in with email and password.
44
359
  */
45
360
  async signIn(options) {
46
- const result = await this.pb.collection(this._collection).authWithPassword(options.email, options.password);
47
- return {
48
- token: result.token,
49
- record: result.record
50
- };
361
+ const result = await this.http.send(
362
+ `/api/db/collections/${this._collection}/auth-with-password`,
363
+ {
364
+ method: "POST",
365
+ body: { identity: options.email, password: options.password }
366
+ }
367
+ );
368
+ this.authStore.save(result.token, result.record);
369
+ return { token: result.token, record: result.record };
51
370
  }
52
371
  /**
53
372
  * Sign in with an OAuth2 provider (Google, GitHub, etc.).
54
- *
55
- * In browser environments this opens a popup/redirect to the provider.
56
- * Configure providers in your PicoBase dashboard.
57
373
  */
58
374
  async signInWithOAuth(options) {
59
- const result = await this.pb.collection(this._collection).authWithOAuth2({
60
- provider: options.provider,
61
- scopes: options.scopes,
62
- createData: options.createData,
63
- urlCallback: options.urlCallback
64
- });
65
- return {
66
- token: result.token,
67
- record: result.record
68
- };
375
+ const result = await this.http.send(
376
+ `/api/db/collections/${this._collection}/auth-with-oauth2`,
377
+ {
378
+ method: "POST",
379
+ body: {
380
+ provider: options.provider,
381
+ scopes: options.scopes,
382
+ createData: options.createData
383
+ }
384
+ }
385
+ );
386
+ this.authStore.save(result.token, result.record);
387
+ return { token: result.token, record: result.record };
69
388
  }
70
389
  /**
71
390
  * Refresh the current auth token.
72
391
  */
73
392
  async refreshToken() {
74
- const result = await this.pb.collection(this._collection).authRefresh();
393
+ const result = await this.http.send(
394
+ `/api/db/collections/${this._collection}/auth-refresh`,
395
+ { method: "POST" }
396
+ );
397
+ this.authStore.save(result.token, result.record);
75
398
  this._notify("TOKEN_REFRESHED", result.record);
76
- return {
77
- token: result.token,
78
- record: result.record
79
- };
399
+ return { token: result.token, record: result.record };
80
400
  }
81
401
  /**
82
402
  * Send a password reset email.
83
403
  */
84
404
  async requestPasswordReset(email) {
85
- await this.pb.collection(this._collection).requestPasswordReset(email);
405
+ await this.http.send(
406
+ `/api/db/collections/${this._collection}/request-password-reset`,
407
+ { method: "POST", body: { email } }
408
+ );
86
409
  }
87
410
  /**
88
411
  * Confirm a password reset with the token from the reset email.
89
412
  */
90
413
  async confirmPasswordReset(token, password, passwordConfirm) {
91
- await this.pb.collection(this._collection).confirmPasswordReset(token, password, passwordConfirm ?? password);
414
+ await this.http.send(
415
+ `/api/db/collections/${this._collection}/confirm-password-reset`,
416
+ { method: "POST", body: { token, password, passwordConfirm: passwordConfirm ?? password } }
417
+ );
92
418
  }
93
419
  /**
94
420
  * Send an email verification email.
95
421
  */
96
422
  async requestVerification(email) {
97
- await this.pb.collection(this._collection).requestVerification(email);
423
+ await this.http.send(
424
+ `/api/db/collections/${this._collection}/request-verification`,
425
+ { method: "POST", body: { email } }
426
+ );
98
427
  }
99
428
  /**
100
429
  * Confirm email verification with the token from the verification email.
101
430
  */
102
431
  async confirmVerification(token) {
103
- await this.pb.collection(this._collection).confirmVerification(token);
432
+ await this.http.send(
433
+ `/api/db/collections/${this._collection}/confirm-verification`,
434
+ { method: "POST", body: { token } }
435
+ );
104
436
  }
105
437
  /**
106
438
  * Sign out the current user. Clears the local auth store.
107
439
  */
108
440
  signOut() {
109
- this.pb.authStore.clear();
441
+ this.authStore.clear();
110
442
  }
111
443
  /**
112
444
  * Get the currently authenticated user record, or `null` if not signed in.
113
445
  */
114
446
  get user() {
115
- return this.pb.authStore.record;
447
+ return this.authStore.record;
116
448
  }
117
449
  /**
118
450
  * Get the current auth token, or empty string if not signed in.
119
451
  */
120
452
  get token() {
121
- return this.pb.authStore.token;
453
+ return this.authStore.token;
122
454
  }
123
455
  /**
124
456
  * Check if the current auth session is valid (token exists and not expired).
125
457
  */
126
458
  get isValid() {
127
- return this.pb.authStore.isValid;
459
+ return this.authStore.isValid;
128
460
  }
129
461
  /**
130
462
  * Listen to auth state changes. Returns an unsubscribe function.
@@ -132,9 +464,7 @@ var PicoBaseAuth = class {
132
464
  * @example
133
465
  * ```ts
134
466
  * const unsubscribe = pb.auth.onStateChange((event, record) => {
135
- * if (event === 'SIGNED_IN') {
136
- * console.log('Welcome', record.email)
137
- * }
467
+ * if (event === 'SIGNED_IN') console.log('Welcome', record.email)
138
468
  * })
139
469
  *
140
470
  * // Later:
@@ -159,9 +489,10 @@ var PicoBaseAuth = class {
159
489
 
160
490
  // src/collection.ts
161
491
  var PicoBaseCollection = class {
162
- constructor(pb, name) {
163
- this.pb = pb;
492
+ constructor(http, name, realtime) {
493
+ this.http = http;
164
494
  this.name = name;
495
+ this.rt = realtime;
165
496
  }
166
497
  /**
167
498
  * Fetch a paginated list of records.
@@ -171,7 +502,17 @@ var PicoBaseCollection = class {
171
502
  * @param options - Filter, sort, expand, fields.
172
503
  */
173
504
  async getList(page = 1, perPage = 30, options = {}) {
174
- return this.pb.collection(this.name).getList(page, perPage, options);
505
+ const { sort, filter, expand, fields, skipTotal, ...rest } = options;
506
+ const query = { page, perPage, ...rest };
507
+ if (sort) query.sort = sort;
508
+ if (filter) query.filter = filter;
509
+ if (expand) query.expand = expand;
510
+ if (fields) query.fields = fields;
511
+ if (skipTotal) query.skipTotal = "1";
512
+ return this.http.send(
513
+ `/api/db/collections/${this.name}/records`,
514
+ { method: "GET", query }
515
+ );
175
516
  }
176
517
  /**
177
518
  * Fetch all records matching the filter (auto-paginates).
@@ -179,13 +520,28 @@ var PicoBaseCollection = class {
179
520
  * **Warning:** Use with caution on large collections. Prefer `getList()` with pagination.
180
521
  */
181
522
  async getFullList(options = {}) {
182
- return this.pb.collection(this.name).getFullList(options);
523
+ const all = [];
524
+ let page = 1;
525
+ const perPage = 200;
526
+ while (true) {
527
+ const result = await this.getList(page, perPage, options);
528
+ all.push(...result.items);
529
+ if (page >= result.totalPages) break;
530
+ page++;
531
+ }
532
+ return all;
183
533
  }
184
534
  /**
185
535
  * Fetch a single record by ID.
186
536
  */
187
537
  async getOne(id, options = {}) {
188
- return this.pb.collection(this.name).getOne(id, options);
538
+ const query = {};
539
+ if (options.expand) query.expand = options.expand;
540
+ if (options.fields) query.fields = options.fields;
541
+ return this.http.send(
542
+ `/api/db/collections/${this.name}/records/${id}`,
543
+ { method: "GET", query: Object.keys(query).length ? query : void 0 }
544
+ );
189
545
  }
190
546
  /**
191
547
  * Fetch the first record matching a filter.
@@ -196,7 +552,11 @@ var PicoBaseCollection = class {
196
552
  * ```
197
553
  */
198
554
  async getFirstListItem(filter, options = {}) {
199
- return this.pb.collection(this.name).getFirstListItem(filter, options);
555
+ const result = await this.getList(1, 1, { filter, ...options });
556
+ if (!result.items.length) {
557
+ throw new RecordNotFoundError(this.name, filter);
558
+ }
559
+ return result.items[0];
200
560
  }
201
561
  /**
202
562
  * Create a new record.
@@ -204,7 +564,17 @@ var PicoBaseCollection = class {
204
564
  * @param data - Record data. Can be a plain object or `FormData` (for file uploads).
205
565
  */
206
566
  async create(data, options = {}) {
207
- return this.pb.collection(this.name).create(data, options);
567
+ const query = {};
568
+ if (options.expand) query.expand = options.expand;
569
+ if (options.fields) query.fields = options.fields;
570
+ return this.http.send(
571
+ `/api/db/collections/${this.name}/records`,
572
+ {
573
+ method: "POST",
574
+ body: data,
575
+ query: Object.keys(query).length ? query : void 0
576
+ }
577
+ );
208
578
  }
209
579
  /**
210
580
  * Update an existing record.
@@ -213,19 +583,33 @@ var PicoBaseCollection = class {
213
583
  * @param data - Fields to update. Can be a plain object or `FormData`.
214
584
  */
215
585
  async update(id, data, options = {}) {
216
- return this.pb.collection(this.name).update(id, data, options);
586
+ const query = {};
587
+ if (options.expand) query.expand = options.expand;
588
+ if (options.fields) query.fields = options.fields;
589
+ return this.http.send(
590
+ `/api/db/collections/${this.name}/records/${id}`,
591
+ {
592
+ method: "PATCH",
593
+ body: data,
594
+ query: Object.keys(query).length ? query : void 0
595
+ }
596
+ );
217
597
  }
218
598
  /**
219
599
  * Delete a record by ID.
220
600
  */
221
601
  async delete(id) {
222
- return this.pb.collection(this.name).delete(id);
602
+ await this.http.send(
603
+ `/api/db/collections/${this.name}/records/${id}`,
604
+ { method: "DELETE" }
605
+ );
606
+ return true;
223
607
  }
224
608
  /**
225
609
  * Subscribe to realtime changes on this collection.
226
610
  *
227
611
  * @param callback - Called on every create/update/delete event.
228
- * @param filter - Optional: only receive events matching this filter.
612
+ * @param filter - Optional: only receive events matching this filter (client-side).
229
613
  * @returns Unsubscribe function.
230
614
  *
231
615
  * @example
@@ -239,9 +623,7 @@ var PicoBaseCollection = class {
239
623
  * ```
240
624
  */
241
625
  async subscribe(callback, filter) {
242
- const topic = "*";
243
- await this.pb.collection(this.name).subscribe(topic, callback, filter ? { filter } : void 0);
244
- return () => this.pb.collection(this.name).unsubscribe(topic);
626
+ return this.rt.subscribe(this.name, callback);
245
627
  }
246
628
  /**
247
629
  * Subscribe to changes on a specific record.
@@ -251,15 +633,20 @@ var PicoBaseCollection = class {
251
633
  * @returns Unsubscribe function.
252
634
  */
253
635
  async subscribeOne(id, callback) {
254
- await this.pb.collection(this.name).subscribe(id, callback);
255
- return () => this.pb.collection(this.name).unsubscribe(id);
636
+ return this.rt.subscribeRecord(this.name, id, callback);
256
637
  }
257
638
  };
258
639
 
259
640
  // src/realtime.ts
260
641
  var PicoBaseRealtime = class {
261
- constructor(pb) {
262
- this.pb = pb;
642
+ constructor(baseUrl, apiKey, getToken) {
643
+ this.eventSource = null;
644
+ // key = collection or collection/recordId
645
+ this.subs = /* @__PURE__ */ new Map();
646
+ this.counter = 0;
647
+ this.baseUrl = baseUrl;
648
+ this.apiKey = apiKey;
649
+ this.getToken = getToken;
263
650
  }
264
651
  /**
265
652
  * Subscribe to realtime events on a collection.
@@ -269,8 +656,11 @@ var PicoBaseRealtime = class {
269
656
  * @returns Unsubscribe function.
270
657
  */
271
658
  async subscribe(collection, callback) {
272
- await this.pb.collection(collection).subscribe("*", callback);
273
- return () => this.pb.collection(collection).unsubscribe("*");
659
+ const id = this._addSub({ collection, callback });
660
+ this._ensureConnection();
661
+ return async () => {
662
+ this._removeSub(id);
663
+ };
274
664
  }
275
665
  /**
276
666
  * Subscribe to realtime events on a specific record.
@@ -281,242 +671,217 @@ var PicoBaseRealtime = class {
281
671
  * @returns Unsubscribe function.
282
672
  */
283
673
  async subscribeRecord(collection, recordId, callback) {
284
- await this.pb.collection(collection).subscribe(recordId, callback);
285
- return () => this.pb.collection(collection).unsubscribe(recordId);
674
+ const id = this._addSub({ collection, recordId, callback });
675
+ this._ensureConnection();
676
+ return async () => {
677
+ this._removeSub(id);
678
+ };
286
679
  }
287
680
  /**
288
681
  * Unsubscribe from all realtime events on a collection.
289
682
  */
290
683
  async unsubscribe(collection) {
291
- await this.pb.collection(collection).unsubscribe();
684
+ this.subs.delete(collection);
685
+ this._pruneIfEmpty();
292
686
  }
293
687
  /**
294
- * Unsubscribe from ALL realtime events. The SSE connection will be
295
- * automatically closed when there are no remaining subscriptions.
688
+ * Unsubscribe from ALL realtime events and close the SSE connection.
296
689
  */
297
690
  async disconnectAll() {
298
- await this.pb.realtime.unsubscribe();
691
+ this.subs.clear();
692
+ this._closeConnection();
693
+ }
694
+ // ── Private ───────────────────────────────────────────────────────────────
695
+ _addSub(sub) {
696
+ const id = `sub_${++this.counter}`;
697
+ const key = sub.recordId ? `${sub.collection}/${sub.recordId}` : sub.collection;
698
+ if (!this.subs.has(key)) this.subs.set(key, /* @__PURE__ */ new Set());
699
+ this.subs.get(key).add({ ...sub, _id: id });
700
+ return id;
701
+ }
702
+ _removeSub(id) {
703
+ for (const [key, set] of this.subs) {
704
+ for (const sub of set) {
705
+ if (sub._id === id) {
706
+ set.delete(sub);
707
+ if (set.size === 0) this.subs.delete(key);
708
+ break;
709
+ }
710
+ }
711
+ }
712
+ this._pruneIfEmpty();
713
+ }
714
+ _pruneIfEmpty() {
715
+ if (this.subs.size === 0) this._closeConnection();
716
+ }
717
+ /**
718
+ * Open SSE connection to `/api/db/realtime`.
719
+ * No-ops in environments where EventSource is unavailable (SSR / Node).
720
+ */
721
+ _ensureConnection() {
722
+ if (this.eventSource) return;
723
+ if (typeof EventSource === "undefined") return;
724
+ const url = new URL(`${this.baseUrl}/api/db/realtime`);
725
+ url.searchParams.set("apiKey", this.apiKey);
726
+ const token = this.getToken();
727
+ if (token) url.searchParams.set("token", token);
728
+ const es = new EventSource(url.toString());
729
+ this.eventSource = es;
730
+ es.onmessage = (event) => {
731
+ try {
732
+ const data = JSON.parse(event.data);
733
+ this._dispatch(data);
734
+ } catch {
735
+ }
736
+ };
737
+ for (const action of ["create", "update", "delete"]) {
738
+ es.addEventListener(action, (event) => {
739
+ try {
740
+ const data = JSON.parse(event.data);
741
+ this._dispatch(data);
742
+ } catch {
743
+ }
744
+ });
745
+ }
746
+ es.onerror = () => {
747
+ if (this.subs.size === 0) this._closeConnection();
748
+ };
749
+ }
750
+ _closeConnection() {
751
+ if (this.eventSource) {
752
+ this.eventSource.close();
753
+ this.eventSource = null;
754
+ }
755
+ }
756
+ _dispatch(event) {
757
+ const payload = {
758
+ action: event.action,
759
+ record: event.record
760
+ };
761
+ const collSubs = this.subs.get(event.collection);
762
+ if (collSubs) {
763
+ for (const sub of collSubs) {
764
+ try {
765
+ sub.callback(payload);
766
+ } catch {
767
+ }
768
+ }
769
+ }
770
+ const recordKey = `${event.collection}/${event.record?.id}`;
771
+ const recSubs = this.subs.get(recordKey);
772
+ if (recSubs) {
773
+ for (const sub of recSubs) {
774
+ try {
775
+ sub.callback(payload);
776
+ } catch {
777
+ }
778
+ }
779
+ }
299
780
  }
300
781
  };
301
782
 
302
783
  // src/storage.ts
303
784
  var PicoBaseStorage = class {
304
- constructor(pb) {
305
- this.pb = pb;
785
+ constructor(baseUrl, http) {
786
+ this.baseUrl = baseUrl;
787
+ this.http = http;
306
788
  }
307
789
  /**
308
- * Get the public URL for a file attached to a record.
790
+ * Get the URL for a file attached to a record.
309
791
  *
310
- * @param record - The record that owns the file.
792
+ * @param record - The record that owns the file. Must have `collectionName` and `id`.
311
793
  * @param filename - The filename (as stored in the record's file field).
312
794
  * @param options - Optional: thumb size, token for protected files, download flag.
313
795
  */
314
796
  getFileUrl(record, filename, options = {}) {
315
- const queryParams = {};
316
- if (options.thumb) {
317
- queryParams["thumb"] = options.thumb;
318
- }
319
- if (options.token) {
320
- queryParams["token"] = options.token;
321
- }
322
- if (options.download) {
323
- queryParams["download"] = "1";
324
- }
325
- return this.pb.files.getURL(record, filename, queryParams);
797
+ const collection = record.collectionName ?? "";
798
+ const recordId = record.id;
799
+ let url = `${this.baseUrl}/api/db/files/${collection}/${recordId}/${filename}`;
800
+ const params = new URLSearchParams();
801
+ if (options.thumb) params.set("thumb", options.thumb);
802
+ if (options.token) params.set("token", options.token);
803
+ if (options.download) params.set("download", "1");
804
+ const qs = params.toString();
805
+ if (qs) url += `?${qs}`;
806
+ return url;
326
807
  }
327
808
  /**
328
- * Generate a temporary file access token.
329
- *
330
- * Use this for accessing protected files. Tokens are short-lived.
809
+ * Generate a temporary file access token for protected files.
810
+ * Tokens are short-lived. Available after Phase 7 ships.
331
811
  */
332
812
  async getFileToken() {
333
- return this.pb.files.getToken();
813
+ const result = await this.http.send(
814
+ "/api/db/files/token",
815
+ { method: "POST" }
816
+ );
817
+ return result.token;
334
818
  }
335
819
  };
336
820
 
337
821
  // src/admin.ts
338
822
  var PicoBaseAdmin = class {
339
- constructor(pb) {
340
- this.pb = pb;
823
+ constructor(http) {
824
+ this.http = http;
341
825
  }
342
826
  /**
343
827
  * Fetch a list of all collections.
344
828
  */
345
829
  async listCollections() {
346
- const result = await this.pb.send("/api/collections", {
347
- method: "GET"
348
- });
349
- return result.items || [];
830
+ const result = await this.http.send(
831
+ "/api/db/collections",
832
+ { method: "GET" }
833
+ );
834
+ return result.items ?? [];
350
835
  }
351
836
  /**
352
837
  * Fetch a single collection by ID or name.
353
838
  */
354
839
  async getCollection(idOrName) {
355
- return this.pb.send(`/api/collections/${idOrName}`, {
356
- method: "GET"
357
- });
840
+ return this.http.send(
841
+ `/api/db/collections/${idOrName}`,
842
+ { method: "GET" }
843
+ );
358
844
  }
359
845
  /**
360
846
  * Create a new collection.
847
+ *
848
+ * @param data.type - `"flexible"` (JSONB, schema-free) or `"strict"` (typed SQL columns).
361
849
  */
362
850
  async createCollection(data) {
363
- return this.pb.send("/api/collections", {
364
- method: "POST",
365
- body: data
366
- });
851
+ return this.http.send(
852
+ "/api/db/collections",
853
+ { method: "POST", body: data }
854
+ );
367
855
  }
368
856
  /**
369
857
  * Update an existing collection.
370
858
  */
371
859
  async updateCollection(idOrName, data) {
372
- return this.pb.send(`/api/collections/${idOrName}`, {
373
- method: "PATCH",
374
- body: data
375
- });
860
+ return this.http.send(
861
+ `/api/db/collections/${idOrName}`,
862
+ { method: "PATCH", body: data }
863
+ );
376
864
  }
377
865
  /**
378
866
  * Delete a collection.
379
867
  */
380
868
  async deleteCollection(idOrName) {
381
869
  try {
382
- await this.pb.send(`/api/collections/${idOrName}`, {
383
- method: "DELETE"
384
- });
870
+ await this.http.send(
871
+ `/api/db/collections/${idOrName}`,
872
+ { method: "DELETE" }
873
+ );
385
874
  return true;
386
- } catch (e) {
875
+ } catch {
387
876
  return false;
388
877
  }
389
878
  }
390
879
  };
391
880
 
392
- // src/errors.ts
393
- var PicoBaseError = class extends Error {
394
- constructor(message, code, status, details, fix) {
395
- super(message);
396
- this.code = code;
397
- this.status = status;
398
- this.details = details;
399
- this.fix = fix;
400
- this.name = "PicoBaseError";
401
- }
402
- /** Formatted error string including fix suggestion. */
403
- toString() {
404
- let s = `${this.name} [${this.code}]: ${this.message}`;
405
- if (this.fix) s += `
406
- Fix: ${this.fix}`;
407
- return s;
408
- }
409
- };
410
- var InstanceUnavailableError = class extends PicoBaseError {
411
- constructor(message = "Instance is not available. It may be stopped or starting up.") {
412
- super(
413
- message,
414
- "INSTANCE_UNAVAILABLE",
415
- 503,
416
- void 0,
417
- "Check your instance status in the PicoBase dashboard, or wait a few seconds and retry. If this persists, your instance may have been stopped \u2014 restart it with `picobase status`."
418
- );
419
- this.name = "InstanceUnavailableError";
420
- }
421
- };
422
- var AuthorizationError = class extends PicoBaseError {
423
- constructor(message = "Invalid or missing API key.") {
424
- super(
425
- message,
426
- "UNAUTHORIZED",
427
- 401,
428
- void 0,
429
- 'Make sure PICOBASE_API_KEY is set in your .env file and matches a valid key from your dashboard. Keys start with "pbk_". You can generate a new key at https://picobase.com/dashboard.'
430
- );
431
- this.name = "AuthorizationError";
432
- }
433
- };
434
- var CollectionNotFoundError = class extends PicoBaseError {
435
- constructor(collectionName) {
436
- super(
437
- `Collection "${collectionName}" not found.`,
438
- "COLLECTION_NOT_FOUND",
439
- 404,
440
- { collection: collectionName },
441
- `Make sure the collection "${collectionName}" exists in your PicoBase instance. Collections are auto-created when you first write data, or you can create them manually in the PicoBase dashboard under Collections.`
442
- );
443
- this.name = "CollectionNotFoundError";
444
- }
445
- };
446
- var RecordNotFoundError = class extends PicoBaseError {
447
- constructor(collectionName, recordId) {
448
- super(
449
- `Record "${recordId}" not found in collection "${collectionName}".`,
450
- "RECORD_NOT_FOUND",
451
- 404,
452
- { collection: collectionName, recordId },
453
- 'Check that the record ID is correct. IDs are 15-character alphanumeric strings (e.g., "abc123def456789").'
454
- );
455
- this.name = "RecordNotFoundError";
456
- }
457
- };
458
- var RequestError = class extends PicoBaseError {
459
- constructor(message, status, details) {
460
- const fix = requestErrorFix(status, message);
461
- super(message, "REQUEST_FAILED", status, details, fix);
462
- this.name = "RequestError";
463
- }
464
- };
465
- var ConfigurationError = class extends PicoBaseError {
466
- constructor(message, fix) {
467
- super(message, "CONFIGURATION_ERROR", void 0, void 0, fix);
468
- this.name = "ConfigurationError";
469
- }
470
- };
471
- var RpcError = class extends PicoBaseError {
472
- constructor(functionName, status, details) {
473
- const fix = rpcErrorFix(functionName, status);
474
- super(
475
- `RPC function "${functionName}" failed.`,
476
- "RPC_ERROR",
477
- status,
478
- details,
479
- fix
480
- );
481
- this.name = "RpcError";
482
- }
483
- };
484
- function rpcErrorFix(functionName, status) {
485
- if (status === 404) {
486
- return `The RPC endpoint "/api/rpc/${functionName}" does not exist. Create a custom route in your PocketBase instance to handle this RPC call. See: https://pocketbase.io/docs/js-routing/`;
487
- }
488
- if (status === 400) {
489
- return "Check the parameters you are passing to this RPC function. The function may be expecting different parameters or types.";
490
- }
491
- if (status === 403) {
492
- return "You don't have permission to call this RPC function. Check the authentication requirements for this endpoint in your PocketBase routes.";
493
- }
494
- return "Check your PicoBase instance logs for details about this RPC error. Ensure the custom route is correctly implemented in your PocketBase setup.";
495
- }
496
- function requestErrorFix(status, message) {
497
- switch (status) {
498
- case 400:
499
- return "Check the data you are sending \u2014 a required field may be missing or have the wrong type. Run `picobase typegen` to regenerate types and check your field names.";
500
- case 403:
501
- return "You don't have permission for this action. Check your collection API rules in the dashboard. By default, only authenticated users can read/write records.";
502
- case 404:
503
- if (message.toLowerCase().includes("collection"))
504
- return "This collection does not exist yet. Write a record to auto-create it, or create it in the dashboard.";
505
- return "The requested resource was not found. Double-check IDs and collection names.";
506
- case 413:
507
- return "The request payload is too large. Check file upload size limits in your instance settings.";
508
- case 429:
509
- return "Too many requests. Add a short delay between requests or implement client-side caching.";
510
- default:
511
- return "If this error persists, check your PicoBase dashboard for instance health and logs.";
512
- }
513
- }
514
-
515
881
  // src/client.ts
516
882
  var DEFAULT_OPTIONS = {
517
883
  timeout: 3e4,
518
- maxColdStartRetries: 3,
519
- lang: "en-US"
884
+ maxColdStartRetries: 3
520
885
  };
521
886
  var PicoBaseClient = class {
522
887
  constructor(url, apiKey, options = {}) {
@@ -538,25 +903,19 @@ var PicoBaseClient = class {
538
903
  `Use the full URL: createClient("https://${url}", "...")`
539
904
  );
540
905
  }
541
- this.apiKey = apiKey;
542
- this.options = { ...DEFAULT_OPTIONS, ...options };
543
- const baseUrl = url.replace(/\/+$/, "");
544
- this.pb = new PocketBase(baseUrl);
545
- this.pb.autoCancellation(false);
546
- if (this.options.lang) {
547
- this.pb.lang = this.options.lang;
548
- }
549
- this.pb.beforeSend = (url2, reqInit) => {
550
- const headers = reqInit.headers ?? {};
551
- headers["X-PicoBase-Key"] = this.apiKey;
552
- reqInit.headers = headers;
553
- return { url: url2, options: reqInit };
554
- };
555
- this._wrapSendWithRetry();
556
- this.auth = new PicoBaseAuth(this.pb);
557
- this.realtime = new PicoBaseRealtime(this.pb);
558
- this.storage = new PicoBaseStorage(this.pb);
559
- this.admin = new PicoBaseAdmin(this.pb);
906
+ const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
907
+ this.baseUrl = url.replace(/\/+$/, "");
908
+ this._authStore = new AuthStore();
909
+ this._http = new PicoBaseHttpClient(this.baseUrl, apiKey, {
910
+ timeout: mergedOptions.timeout,
911
+ maxColdStartRetries: mergedOptions.maxColdStartRetries,
912
+ fetch: options.fetch,
913
+ getToken: () => this._authStore.token
914
+ });
915
+ this.auth = new PicoBaseAuth(this._http, this._authStore);
916
+ this.realtime = new PicoBaseRealtime(this.baseUrl, apiKey, () => this._authStore.token);
917
+ this.storage = new PicoBaseStorage(this.baseUrl, this._http);
918
+ this.admin = new PicoBaseAdmin(this._http);
560
919
  }
561
920
  /**
562
921
  * Access a collection for CRUD operations.
@@ -567,40 +926,23 @@ var PicoBaseClient = class {
567
926
  * ```
568
927
  */
569
928
  collection(name) {
570
- return new PicoBaseCollection(this.pb, name);
929
+ return new PicoBaseCollection(this._http, name, this.realtime);
571
930
  }
572
931
  /**
573
- * Call a server-side function (PocketBase custom API endpoint).
574
- * Proxies to PocketBase's send() method.
932
+ * Send a raw HTTP request to the PicoBase API.
575
933
  */
576
934
  async send(path, options) {
577
- return this.pb.send(path, options ?? {});
935
+ return this._http.send(path, options);
578
936
  }
579
937
  /**
580
- * Call a remote procedure (RPC) - a convenience method for calling custom API endpoints.
938
+ * Call a remote procedure (RPC) convenience wrapper for custom API endpoints.
581
939
  *
582
- * Maps Supabase-style RPC calls to PicoBase custom endpoints:
583
- * - `pb.rpc('my_function', params)` → `POST /api/rpc/my_function` with params as body
940
+ * Maps Supabase-style RPC calls: `pb.rpc('my_fn', params)` `POST /api/rpc/my_fn`
584
941
  *
585
942
  * @example
586
943
  * ```ts
587
- * // Simple RPC call
588
944
  * const result = await pb.rpc('calculate_total', { cart_id: '123' })
589
- *
590
- * // Complex RPC with typed response
591
- * interface DashboardStats {
592
- * posts: number
593
- * comments: number
594
- * followers: number
595
- * }
596
- * const stats = await pb.rpc<DashboardStats>('get_dashboard_stats', {
597
- * user_id: currentUser.id
598
- * })
599
945
  * ```
600
- *
601
- * @param functionName The name of the RPC function to call
602
- * @param params Optional parameters to pass to the function
603
- * @returns The function result
604
946
  */
605
947
  async rpc(functionName, params) {
606
948
  try {
@@ -610,29 +952,19 @@ var PicoBaseClient = class {
610
952
  });
611
953
  } catch (err) {
612
954
  const status = err?.status ?? 500;
613
- const details = err?.data;
955
+ const details = err?.details;
614
956
  throw new RpcError(functionName, status, details);
615
957
  }
616
958
  }
617
959
  /**
618
- * Proactively wake up the instance if it's sleeping and wait for it to be ready.
619
- * Useful to call when a user navigates to a login page or before critical operations,
620
- * to absorb the cold-start latency upfront.
621
- *
622
- * This method guarantees that the instance is fully running and able to serve requests
623
- * when the promise resolves.
624
- *
625
- * @example
626
- * ```ts
627
- * // In a useEffect on your login page:
628
- * useEffect(() => {
629
- * pb.wake().catch(console.error)
630
- * }, [])
631
- * ```
960
+ * Proactively warm up the Neon connection after scale-to-zero.
961
+ *
962
+ * Useful to call when a user navigates to a login page or before critical
963
+ * operations, to absorb cold-start latency upfront.
632
964
  */
633
965
  async wake() {
634
966
  try {
635
- await this.send("/api/health", { method: "GET" });
967
+ await this.send("/api/db/health", { method: "GET" });
636
968
  return true;
637
969
  } catch (err) {
638
970
  console.warn("Failed to wake instance:", err);
@@ -643,57 +975,13 @@ var PicoBaseClient = class {
643
975
  * Get the current auth token (if signed in), or empty string.
644
976
  */
645
977
  get token() {
646
- return this.pb.authStore.token;
978
+ return this._authStore.token;
647
979
  }
648
980
  /**
649
981
  * Check if a user is currently authenticated.
650
982
  */
651
983
  get isAuthenticated() {
652
- return this.pb.authStore.isValid;
653
- }
654
- /**
655
- * Monkey-patch pb.send to retry on 503 (cold start).
656
- *
657
- * When an instance is stopped or starting, the proxy returns 503.
658
- * The SDK automatically retries with exponential backoff so the developer
659
- * doesn't have to handle cold-start logic.
660
- */
661
- _wrapSendWithRetry() {
662
- const originalSend = this.pb.send.bind(this.pb);
663
- const maxRetries = this.options.maxColdStartRetries;
664
- this.pb.send = async (path, options) => {
665
- let lastError;
666
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
667
- try {
668
- return await originalSend(path, options);
669
- } catch (err) {
670
- lastError = err;
671
- const status = err?.status;
672
- if (status === 503 && attempt < maxRetries) {
673
- const delay = Math.pow(2, attempt + 1) * 1e3;
674
- await new Promise((resolve) => setTimeout(resolve, delay));
675
- continue;
676
- }
677
- if (status === 401) {
678
- const data = err?.data;
679
- if (data?.code === "INVALID_API_KEY") {
680
- throw new AuthorizationError();
681
- }
682
- }
683
- if (status === 404) {
684
- const msg = err?.message ?? "";
685
- if (msg.toLowerCase().includes("missing collection") || msg.toLowerCase().includes("not found collection")) {
686
- const match = msg.match(/["']([^"']+)["']/);
687
- throw new CollectionNotFoundError(match?.[1] ?? "unknown");
688
- }
689
- }
690
- throw err;
691
- }
692
- }
693
- throw new InstanceUnavailableError(
694
- `Instance unavailable after ${maxRetries} retries. Original error: ${lastError instanceof Error ? lastError.message : String(lastError)}`
695
- );
696
- };
984
+ return this._authStore.isValid;
697
985
  }
698
986
  };
699
987
  function createClient(urlOrOptions, apiKeyOrUndefined, options) {