@hanzo/base 0.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.
@@ -0,0 +1,996 @@
1
+ // src/core/state.ts
2
+ var VersionTracker = class {
3
+ constructor(maxHistory = 128) {
4
+ this._history = [];
5
+ this._version = { querySet: 0, ts: 0n, identity: 0 };
6
+ this._maxHistory = maxHistory;
7
+ }
8
+ get current() {
9
+ return { ...this._version };
10
+ }
11
+ get history() {
12
+ return this._history;
13
+ }
14
+ /**
15
+ * Advance the version and record a transition.
16
+ * Returns the new version.
17
+ */
18
+ advance(modifications, serverTs) {
19
+ const start = { ...this._version };
20
+ this._version = {
21
+ querySet: this._version.querySet + 1,
22
+ ts: serverTs ?? this._version.ts,
23
+ identity: this._version.identity
24
+ };
25
+ const transition = {
26
+ startVersion: start,
27
+ endVersion: { ...this._version },
28
+ modifications
29
+ };
30
+ this._history.push(transition);
31
+ if (this._history.length > this._maxHistory) {
32
+ this._history.shift();
33
+ }
34
+ return { ...this._version };
35
+ }
36
+ /** Update identity hash (e.g. on auth change). */
37
+ setIdentity(identity) {
38
+ this._version = { ...this._version, identity };
39
+ }
40
+ /** Update the high-water timestamp without bumping querySet. */
41
+ updateTimestamp(ts) {
42
+ if (ts > this._version.ts) {
43
+ this._version = { ...this._version, ts };
44
+ }
45
+ }
46
+ /** Simple FNV-1a-like hash for identity derivation. */
47
+ static hashIdentity(token) {
48
+ let h = 2166136261;
49
+ for (let i = 0; i < token.length; i++) {
50
+ h ^= token.charCodeAt(i);
51
+ h = Math.imul(h, 16777619);
52
+ }
53
+ return h >>> 0;
54
+ }
55
+ };
56
+
57
+ // src/core/store.ts
58
+ function queryHash(collection, filter) {
59
+ return `${collection}::${filter}`;
60
+ }
61
+ function mergeOptimistic(server, optimistic, collection) {
62
+ const map = /* @__PURE__ */ new Map();
63
+ for (const r of server) {
64
+ map.set(r.id, r);
65
+ }
66
+ for (const entry of optimistic) {
67
+ if (entry.collection !== collection) continue;
68
+ if (entry.record === null && entry.deletedId) {
69
+ map.delete(entry.deletedId);
70
+ } else if (entry.record) {
71
+ map.set(entry.record.id, entry.record);
72
+ }
73
+ }
74
+ return Array.from(map.values());
75
+ }
76
+ var QueryStore = class {
77
+ constructor() {
78
+ this._slots = /* @__PURE__ */ new Map();
79
+ this._optimistic = [];
80
+ this._version = new VersionTracker();
81
+ }
82
+ get version() {
83
+ return this._version.current;
84
+ }
85
+ // ---- Query cache --------------------------------------------------------
86
+ /** Return cached effective (server+optimistic) result or undefined. */
87
+ getQuery(collection, filter = "") {
88
+ const slot = this._slots.get(queryHash(collection, filter));
89
+ if (!slot) return void 0;
90
+ return mergeOptimistic(slot.serverRecords, this._optimistic, collection);
91
+ }
92
+ /** Overwrite the server-truth cache for a query and notify. */
93
+ setQuery(collection, filter, data) {
94
+ const hash = queryHash(collection, filter);
95
+ let slot = this._slots.get(hash);
96
+ if (!slot) {
97
+ slot = { key: { collection, filter }, serverRecords: [], listeners: /* @__PURE__ */ new Set() };
98
+ this._slots.set(hash, slot);
99
+ }
100
+ slot.serverRecords = data;
101
+ this._version.advance(
102
+ data.map((r) => ({ type: "QueryUpdated", collection, record: r }))
103
+ );
104
+ this._notify(slot);
105
+ }
106
+ // ---- Optimistic mutations -----------------------------------------------
107
+ /** Apply an optimistic create/update. Returns a mutationId for rollback. */
108
+ optimisticSet(collection, record) {
109
+ const mutationId = `opt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
110
+ this._optimistic.push({
111
+ mutationId,
112
+ collection,
113
+ record,
114
+ createdAt: Date.now()
115
+ });
116
+ this._notifyCollection(collection);
117
+ return mutationId;
118
+ }
119
+ /** Apply an optimistic delete. */
120
+ optimisticDelete(collection, id) {
121
+ const mutationId = `opt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
122
+ this._optimistic.push({
123
+ mutationId,
124
+ collection,
125
+ record: null,
126
+ deletedId: id,
127
+ createdAt: Date.now()
128
+ });
129
+ this._notifyCollection(collection);
130
+ return mutationId;
131
+ }
132
+ /** Remove a single optimistic mutation and re-derive. */
133
+ rollbackOptimistic(mutationId) {
134
+ const idx = this._optimistic.findIndex((e) => e.mutationId === mutationId);
135
+ if (idx === -1) return;
136
+ const entry = this._optimistic[idx];
137
+ this._optimistic.splice(idx, 1);
138
+ this._notifyCollection(entry.collection);
139
+ }
140
+ /** Drop all optimistic entries for a collection. */
141
+ clearOptimistic(collection) {
142
+ for (let i = this._optimistic.length - 1; i >= 0; i--) {
143
+ if (this._optimistic[i].collection === collection) {
144
+ this._optimistic.splice(i, 1);
145
+ }
146
+ }
147
+ this._notifyCollection(collection);
148
+ }
149
+ // ---- Server event ingestion ---------------------------------------------
150
+ /**
151
+ * Apply a realtime SSE event from the server.
152
+ * `action` is one of "create", "update", "delete".
153
+ */
154
+ applyServerUpdate(collection, action, record) {
155
+ const mods = [];
156
+ for (const slot of this._slots.values()) {
157
+ if (slot.key.collection !== collection) continue;
158
+ if (action === "delete") {
159
+ const before = slot.serverRecords.length;
160
+ slot.serverRecords = slot.serverRecords.filter((r) => r.id !== record.id);
161
+ if (slot.serverRecords.length !== before) {
162
+ mods.push({ type: "QueryRemoved", collection, id: record.id });
163
+ }
164
+ } else {
165
+ const idx = slot.serverRecords.findIndex((r) => r.id === record.id);
166
+ if (idx >= 0) {
167
+ slot.serverRecords[idx] = record;
168
+ } else {
169
+ slot.serverRecords.push(record);
170
+ }
171
+ mods.push({ type: "QueryUpdated", collection, record });
172
+ }
173
+ }
174
+ if (mods.length > 0) {
175
+ const ts = record.updated ? BigInt(new Date(record.updated).getTime()) * 1000n : void 0;
176
+ this._version.advance(mods, ts);
177
+ }
178
+ this._notifyCollection(collection);
179
+ }
180
+ // ---- Subscriptions ------------------------------------------------------
181
+ /** Subscribe to effective-state changes for a query. Returns unsubscribe. */
182
+ subscribe(collection, filter, callback) {
183
+ const hash = queryHash(collection, filter);
184
+ let slot = this._slots.get(hash);
185
+ if (!slot) {
186
+ slot = { key: { collection, filter }, serverRecords: [], listeners: /* @__PURE__ */ new Set() };
187
+ this._slots.set(hash, slot);
188
+ }
189
+ slot.listeners.add(callback);
190
+ return () => {
191
+ slot.listeners.delete(callback);
192
+ if (slot.listeners.size === 0 && slot.serverRecords.length === 0) {
193
+ this._slots.delete(hash);
194
+ }
195
+ };
196
+ }
197
+ // ---- Internal -----------------------------------------------------------
198
+ _notify(slot) {
199
+ if (slot.listeners.size === 0) return;
200
+ const effective = mergeOptimistic(slot.serverRecords, this._optimistic, slot.key.collection);
201
+ for (const cb of slot.listeners) {
202
+ try {
203
+ cb(effective);
204
+ } catch {
205
+ }
206
+ }
207
+ }
208
+ _notifyCollection(collection) {
209
+ for (const slot of this._slots.values()) {
210
+ if (slot.key.collection === collection) {
211
+ this._notify(slot);
212
+ }
213
+ }
214
+ }
215
+ };
216
+
217
+ // src/core/realtime.ts
218
+ var RealtimeService = class {
219
+ constructor(baseUrl, getToken) {
220
+ this._eventSource = null;
221
+ this._state = "disconnected";
222
+ this._connectionListeners = /* @__PURE__ */ new Set();
223
+ /** Dedup map: hash -> Subscription. */
224
+ this._subscriptions = /* @__PURE__ */ new Map();
225
+ /** SSE client id assigned by the server on connect. */
226
+ this._clientId = null;
227
+ /** Reconnect state. */
228
+ this._reconnectAttempts = 0;
229
+ this._reconnectTimer = null;
230
+ this._maxReconnectDelay = 3e4;
231
+ this._baseReconnectDelay = 500;
232
+ /** High-water mark of observed server timestamps. */
233
+ this._maxTimestamp = 0n;
234
+ /** Set to true when disconnect() is called explicitly. */
235
+ this._intentionalDisconnect = false;
236
+ this._baseUrl = baseUrl.replace(/\/$/, "");
237
+ this._getToken = getToken;
238
+ }
239
+ // ---- Public API ---------------------------------------------------------
240
+ get state() {
241
+ return this._state;
242
+ }
243
+ get maxTimestamp() {
244
+ return this._maxTimestamp;
245
+ }
246
+ /**
247
+ * Subscribe to realtime events for a collection topic.
248
+ *
249
+ * Topic is usually "*" (all changes) or a record id.
250
+ * Returns an unsubscribe function.
251
+ */
252
+ subscribe(collection, topic, callback) {
253
+ const hash = this._hash(collection, topic);
254
+ let sub = this._subscriptions.get(hash);
255
+ if (!sub) {
256
+ sub = { collection, topic, callbacks: /* @__PURE__ */ new Set() };
257
+ this._subscriptions.set(hash, sub);
258
+ }
259
+ sub.callbacks.add(callback);
260
+ if (this._subscriptions.size === 1 && !this._eventSource) {
261
+ this._connect();
262
+ } else if (this._clientId) {
263
+ this._submitSubscriptions();
264
+ }
265
+ return () => {
266
+ sub.callbacks.delete(callback);
267
+ if (sub.callbacks.size === 0) {
268
+ this._subscriptions.delete(hash);
269
+ if (this._clientId) {
270
+ this._submitSubscriptions();
271
+ }
272
+ }
273
+ if (this._subscriptions.size === 0) {
274
+ this.disconnect();
275
+ }
276
+ };
277
+ }
278
+ /** Remove all subscribers for a topic, or all topics if none specified. */
279
+ unsubscribe(topic) {
280
+ if (topic) {
281
+ this._subscriptions.delete(topic);
282
+ } else {
283
+ this._subscriptions.clear();
284
+ }
285
+ if (this._subscriptions.size === 0) {
286
+ this.disconnect();
287
+ }
288
+ }
289
+ /** Register a connection-state listener. Returns unsubscribe. */
290
+ onConnectionChange(callback) {
291
+ this._connectionListeners.add(callback);
292
+ return () => {
293
+ this._connectionListeners.delete(callback);
294
+ };
295
+ }
296
+ /** Explicitly disconnect. */
297
+ disconnect() {
298
+ this._intentionalDisconnect = true;
299
+ this._clearReconnect();
300
+ if (this._eventSource) {
301
+ this._eventSource.close();
302
+ this._eventSource = null;
303
+ }
304
+ this._clientId = null;
305
+ this._setState("disconnected");
306
+ }
307
+ // ---- Connection ---------------------------------------------------------
308
+ _connect() {
309
+ if (this._eventSource) return;
310
+ this._intentionalDisconnect = false;
311
+ this._setState("connecting");
312
+ const url = `${this._baseUrl}/api/realtime`;
313
+ this._eventSource = new EventSource(url);
314
+ this._eventSource.addEventListener("PB_CONNECT", (e) => {
315
+ try {
316
+ const data = JSON.parse(e.data);
317
+ this._clientId = data.clientId;
318
+ this._reconnectAttempts = 0;
319
+ this._setState("connected");
320
+ this._submitSubscriptions();
321
+ } catch {
322
+ }
323
+ });
324
+ this._eventSource.onmessage = (e) => {
325
+ this._handleMessage(e);
326
+ };
327
+ this._eventSource.onerror = () => {
328
+ this._eventSource?.close();
329
+ this._eventSource = null;
330
+ this._clientId = null;
331
+ this._setState("disconnected");
332
+ if (!this._intentionalDisconnect) {
333
+ this._scheduleReconnect();
334
+ }
335
+ };
336
+ }
337
+ _handleMessage(e) {
338
+ let payload;
339
+ try {
340
+ payload = JSON.parse(e.data);
341
+ } catch {
342
+ return;
343
+ }
344
+ const action = payload.action;
345
+ const record = payload.record;
346
+ if (!record?.id) return;
347
+ const ts = record.updated ?? record.created;
348
+ if (ts) {
349
+ const n = BigInt(new Date(ts).getTime()) * 1000n;
350
+ if (n > this._maxTimestamp) {
351
+ this._maxTimestamp = n;
352
+ }
353
+ }
354
+ const collectionName = record.collectionName ?? "";
355
+ for (const sub of this._subscriptions.values()) {
356
+ if (sub.collection !== collectionName) continue;
357
+ if (sub.topic !== "*" && sub.topic !== record.id) continue;
358
+ const event = { action, record };
359
+ for (const cb of sub.callbacks) {
360
+ try {
361
+ cb(event);
362
+ } catch {
363
+ }
364
+ }
365
+ }
366
+ }
367
+ /** Submit current subscription set to the server via POST. */
368
+ async _submitSubscriptions() {
369
+ if (!this._clientId) return;
370
+ const topics = [];
371
+ for (const sub of this._subscriptions.values()) {
372
+ topics.push(`${sub.collection}/${sub.topic}`);
373
+ }
374
+ const token = this._getToken();
375
+ try {
376
+ await fetch(`${this._baseUrl}/api/realtime`, {
377
+ method: "POST",
378
+ headers: {
379
+ "Content-Type": "application/json",
380
+ ...token ? { Authorization: token } : {}
381
+ },
382
+ body: JSON.stringify({
383
+ clientId: this._clientId,
384
+ subscriptions: topics
385
+ })
386
+ });
387
+ } catch {
388
+ }
389
+ }
390
+ // ---- Reconnect ----------------------------------------------------------
391
+ _scheduleReconnect() {
392
+ this._clearReconnect();
393
+ const delay = Math.min(
394
+ this._baseReconnectDelay * Math.pow(2, this._reconnectAttempts),
395
+ this._maxReconnectDelay
396
+ );
397
+ const jitter = delay * (0.75 + Math.random() * 0.5);
398
+ this._reconnectAttempts++;
399
+ this._reconnectTimer = setTimeout(() => {
400
+ this._reconnectTimer = null;
401
+ this._connect();
402
+ }, jitter);
403
+ }
404
+ _clearReconnect() {
405
+ if (this._reconnectTimer !== null) {
406
+ clearTimeout(this._reconnectTimer);
407
+ this._reconnectTimer = null;
408
+ }
409
+ }
410
+ // ---- State --------------------------------------------------------------
411
+ _setState(state) {
412
+ if (this._state === state) return;
413
+ this._state = state;
414
+ for (const cb of this._connectionListeners) {
415
+ try {
416
+ cb(state);
417
+ } catch {
418
+ }
419
+ }
420
+ }
421
+ // ---- Helpers ------------------------------------------------------------
422
+ _hash(collection, topic) {
423
+ return `${collection}::${topic}`;
424
+ }
425
+ };
426
+
427
+ // src/core/collection.ts
428
+ var CollectionService = class {
429
+ constructor(collectionIdOrName, baseUrl, getToken, setAuth, store, realtime) {
430
+ this.collectionIdOrName = collectionIdOrName;
431
+ this._baseUrl = baseUrl.replace(/\/$/, "");
432
+ this._getToken = getToken;
433
+ this._setAuth = setAuth;
434
+ this._store = store;
435
+ this._realtime = realtime;
436
+ }
437
+ // ---- CRUD ---------------------------------------------------------------
438
+ async getList(page = 1, perPage = 30, options) {
439
+ const params = new URLSearchParams();
440
+ params.set("page", String(page));
441
+ params.set("perPage", String(perPage));
442
+ this._applyOptions(params, options);
443
+ const result = await this._request(
444
+ "GET",
445
+ `${this._collectionPath()}/records?${params}`,
446
+ void 0,
447
+ options
448
+ );
449
+ const cacheFilter = options?.filter ?? "";
450
+ this._store.setQuery(
451
+ this.collectionIdOrName,
452
+ cacheFilter,
453
+ result.items
454
+ );
455
+ return result;
456
+ }
457
+ async getFullList(options) {
458
+ const batch = options?.batch ?? 200;
459
+ let page = 1;
460
+ let all = [];
461
+ while (true) {
462
+ const result = await this.getList(page, batch, options);
463
+ all = all.concat(result.items);
464
+ if (all.length >= result.totalItems || result.items.length < batch) {
465
+ break;
466
+ }
467
+ page++;
468
+ }
469
+ return all;
470
+ }
471
+ async getOne(id, options) {
472
+ const params = new URLSearchParams();
473
+ this._applyOptions(params, options);
474
+ const qs = params.toString();
475
+ const path = `${this._collectionPath()}/records/${encodeURIComponent(id)}${qs ? "?" + qs : ""}`;
476
+ return this._request("GET", path, void 0, options);
477
+ }
478
+ async getFirstListItem(filter, options) {
479
+ const opts = { ...options, filter };
480
+ const result = await this.getList(1, 1, opts);
481
+ if (result.items.length === 0) {
482
+ throw new ClientResponseError({
483
+ url: this._baseUrl,
484
+ status: 404,
485
+ data: { message: "The requested resource wasn't found." }
486
+ });
487
+ }
488
+ return result.items[0];
489
+ }
490
+ async create(data, options) {
491
+ const params = new URLSearchParams();
492
+ this._applyOptions(params, options);
493
+ const qs = params.toString();
494
+ const path = `${this._collectionPath()}/records${qs ? "?" + qs : ""}`;
495
+ let mutationId;
496
+ if (!(data instanceof FormData) && typeof data === "object") {
497
+ const tempId = `__temp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
498
+ const optimistic = {
499
+ id: tempId,
500
+ collectionName: this.collectionIdOrName,
501
+ ...data
502
+ };
503
+ mutationId = this._store.optimisticSet(this.collectionIdOrName, optimistic);
504
+ }
505
+ try {
506
+ const body = data instanceof FormData ? data : JSON.stringify(data);
507
+ const contentType = data instanceof FormData ? void 0 : "application/json";
508
+ const record = await this._request("POST", path, body, options, contentType);
509
+ if (mutationId) {
510
+ this._store.rollbackOptimistic(mutationId);
511
+ }
512
+ this._store.applyServerUpdate(this.collectionIdOrName, "create", record);
513
+ return record;
514
+ } catch (err) {
515
+ if (mutationId) {
516
+ this._store.rollbackOptimistic(mutationId);
517
+ }
518
+ throw err;
519
+ }
520
+ }
521
+ async update(id, data, options) {
522
+ const params = new URLSearchParams();
523
+ this._applyOptions(params, options);
524
+ const qs = params.toString();
525
+ const path = `${this._collectionPath()}/records/${encodeURIComponent(id)}${qs ? "?" + qs : ""}`;
526
+ let mutationId;
527
+ if (!(data instanceof FormData) && typeof data === "object") {
528
+ const optimistic = {
529
+ id,
530
+ collectionName: this.collectionIdOrName,
531
+ ...data
532
+ };
533
+ mutationId = this._store.optimisticSet(this.collectionIdOrName, optimistic);
534
+ }
535
+ try {
536
+ const body = data instanceof FormData ? data : JSON.stringify(data);
537
+ const contentType = data instanceof FormData ? void 0 : "application/json";
538
+ const record = await this._request("PATCH", path, body, options, contentType);
539
+ if (mutationId) {
540
+ this._store.rollbackOptimistic(mutationId);
541
+ }
542
+ this._store.applyServerUpdate(this.collectionIdOrName, "update", record);
543
+ return record;
544
+ } catch (err) {
545
+ if (mutationId) {
546
+ this._store.rollbackOptimistic(mutationId);
547
+ }
548
+ throw err;
549
+ }
550
+ }
551
+ async delete(id, options) {
552
+ const params = new URLSearchParams();
553
+ this._applyOptions(params, options);
554
+ const qs = params.toString();
555
+ const path = `${this._collectionPath()}/records/${encodeURIComponent(id)}${qs ? "?" + qs : ""}`;
556
+ const mutationId = this._store.optimisticDelete(this.collectionIdOrName, id);
557
+ try {
558
+ await this._request("DELETE", path, void 0, options);
559
+ this._store.rollbackOptimistic(mutationId);
560
+ this._store.applyServerUpdate(this.collectionIdOrName, "delete", { id });
561
+ return true;
562
+ } catch (err) {
563
+ this._store.rollbackOptimistic(mutationId);
564
+ throw err;
565
+ }
566
+ }
567
+ // ---- Realtime -----------------------------------------------------------
568
+ /**
569
+ * Subscribe to realtime events for this collection.
570
+ * `topic` is "*" for all changes or a specific record id.
571
+ */
572
+ subscribe(topic, callback) {
573
+ return this._realtime.subscribe(this.collectionIdOrName, topic, callback);
574
+ }
575
+ /** Unsubscribe from a specific topic or all topics for this collection. */
576
+ unsubscribe(topic) {
577
+ this._realtime.unsubscribe(
578
+ topic ? `${this.collectionIdOrName}/${topic}` : this.collectionIdOrName
579
+ );
580
+ }
581
+ // ---- Auth methods (for auth collections) --------------------------------
582
+ async authWithPassword(identity, password, options) {
583
+ const params = new URLSearchParams();
584
+ this._applyOptions(params, options);
585
+ const qs = params.toString();
586
+ const path = `${this._collectionPath()}/auth-with-password${qs ? "?" + qs : ""}`;
587
+ const result = await this._request(
588
+ "POST",
589
+ path,
590
+ JSON.stringify({ identity, password }),
591
+ options,
592
+ "application/json"
593
+ );
594
+ this._setAuth(result.token, result.record);
595
+ return result;
596
+ }
597
+ async authWithOAuth2(oauthOptions, options) {
598
+ const params = new URLSearchParams();
599
+ this._applyOptions(params, options);
600
+ const qs = params.toString();
601
+ const path = `${this._collectionPath()}/auth-with-oauth2${qs ? "?" + qs : ""}`;
602
+ const result = await this._request(
603
+ "POST",
604
+ path,
605
+ JSON.stringify(oauthOptions),
606
+ options,
607
+ "application/json"
608
+ );
609
+ this._setAuth(result.token, result.record);
610
+ return result;
611
+ }
612
+ async requestVerification(email, options) {
613
+ const path = `${this._collectionPath()}/request-verification`;
614
+ await this._request(
615
+ "POST",
616
+ path,
617
+ JSON.stringify({ email }),
618
+ options,
619
+ "application/json"
620
+ );
621
+ return true;
622
+ }
623
+ async confirmVerification(token, options) {
624
+ const path = `${this._collectionPath()}/confirm-verification`;
625
+ await this._request(
626
+ "POST",
627
+ path,
628
+ JSON.stringify({ token }),
629
+ options,
630
+ "application/json"
631
+ );
632
+ return true;
633
+ }
634
+ async requestPasswordReset(email, options) {
635
+ const path = `${this._collectionPath()}/request-password-reset`;
636
+ await this._request(
637
+ "POST",
638
+ path,
639
+ JSON.stringify({ email }),
640
+ options,
641
+ "application/json"
642
+ );
643
+ return true;
644
+ }
645
+ async confirmPasswordReset(token, password, passwordConfirm, options) {
646
+ const path = `${this._collectionPath()}/confirm-password-reset`;
647
+ await this._request(
648
+ "POST",
649
+ path,
650
+ JSON.stringify({ token, password, passwordConfirm }),
651
+ options,
652
+ "application/json"
653
+ );
654
+ return true;
655
+ }
656
+ // ---- Internal -----------------------------------------------------------
657
+ _collectionPath() {
658
+ return `/api/collections/${encodeURIComponent(this.collectionIdOrName)}`;
659
+ }
660
+ _applyOptions(params, options) {
661
+ if (!options) return;
662
+ if (options.filter) params.set("filter", options.filter);
663
+ if (options.sort) params.set("sort", options.sort);
664
+ if (options.expand) params.set("expand", options.expand);
665
+ if (options.fields) params.set("fields", options.fields);
666
+ if (options.query) {
667
+ for (const [k, v] of Object.entries(options.query)) {
668
+ params.set(k, v);
669
+ }
670
+ }
671
+ }
672
+ async _request(method, path, body, options, contentType) {
673
+ const url = `${this._baseUrl}${path}`;
674
+ const token = this._getToken();
675
+ const headers = {
676
+ ...options?.headers ?? {}
677
+ };
678
+ if (token) {
679
+ headers["Authorization"] = token;
680
+ }
681
+ if (contentType) {
682
+ headers["Content-Type"] = contentType;
683
+ }
684
+ const response = await fetch(url, {
685
+ method,
686
+ headers,
687
+ body: body ?? void 0,
688
+ signal: options?.signal
689
+ });
690
+ if (!response.ok) {
691
+ const data = await response.json().catch(() => ({}));
692
+ throw new ClientResponseError({
693
+ url,
694
+ status: response.status,
695
+ data
696
+ });
697
+ }
698
+ if (response.status === 204) {
699
+ return void 0;
700
+ }
701
+ return response.json();
702
+ }
703
+ };
704
+ var ClientResponseError = class extends Error {
705
+ constructor(errorData) {
706
+ const message = errorData.data?.message ?? `ClientResponseError ${errorData.status}`;
707
+ super(message);
708
+ this.name = "ClientResponseError";
709
+ this.url = errorData.url;
710
+ this.status = errorData.status;
711
+ this.data = errorData.data;
712
+ this.isAbort = errorData.status === 0;
713
+ }
714
+ toJSON() {
715
+ return { url: this.url, status: this.status, data: this.data };
716
+ }
717
+ };
718
+
719
+ // src/core/client.ts
720
+ var MemoryAuthStore = class {
721
+ constructor() {
722
+ this._token = "";
723
+ this._record = null;
724
+ this._listeners = /* @__PURE__ */ new Set();
725
+ }
726
+ get token() {
727
+ return this._token;
728
+ }
729
+ get record() {
730
+ return this._record;
731
+ }
732
+ get isValid() {
733
+ if (!this._token) return false;
734
+ try {
735
+ const parts = this._token.split(".");
736
+ if (parts.length !== 3) return false;
737
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
738
+ if (typeof payload.exp === "number") {
739
+ return payload.exp > Date.now() / 1e3;
740
+ }
741
+ return true;
742
+ } catch {
743
+ return false;
744
+ }
745
+ }
746
+ save(token, record) {
747
+ this._token = token;
748
+ this._record = record;
749
+ this._notify();
750
+ }
751
+ clear() {
752
+ this._token = "";
753
+ this._record = null;
754
+ this._notify();
755
+ }
756
+ onChange(callback) {
757
+ this._listeners.add(callback);
758
+ return () => {
759
+ this._listeners.delete(callback);
760
+ };
761
+ }
762
+ _notify() {
763
+ for (const cb of this._listeners) {
764
+ try {
765
+ cb(this._token, this._record);
766
+ } catch {
767
+ }
768
+ }
769
+ }
770
+ };
771
+ var FileService = class {
772
+ constructor(baseUrl) {
773
+ this._baseUrl = baseUrl.replace(/\/$/, "");
774
+ }
775
+ /**
776
+ * Build a full URL to a record file.
777
+ * Compatible with PocketBase's pb.files.getURL().
778
+ */
779
+ getURL(record, filename, options) {
780
+ if (!filename || !record.id) return "";
781
+ const collectionId = record.collectionId ?? record.collectionName ?? "";
782
+ const parts = [
783
+ this._baseUrl,
784
+ "api",
785
+ "files",
786
+ encodeURIComponent(collectionId),
787
+ encodeURIComponent(record.id),
788
+ encodeURIComponent(filename)
789
+ ];
790
+ let url = parts.join("/");
791
+ const params = new URLSearchParams();
792
+ if (options?.thumb) params.set("thumb", options.thumb);
793
+ if (options?.token) params.set("token", options.token);
794
+ const qs = params.toString();
795
+ if (qs) url += "?" + qs;
796
+ return url;
797
+ }
798
+ };
799
+ var BaseClient = class {
800
+ /**
801
+ * Create a BaseClient.
802
+ *
803
+ * Accepts either a config object or a plain URL string for convenience:
804
+ * new BaseClient('https://myapp.hanzo.ai')
805
+ * new BaseClient({ url: 'https://myapp.hanzo.ai' })
806
+ */
807
+ constructor(configOrUrl) {
808
+ this._collections = /* @__PURE__ */ new Map();
809
+ const config = typeof configOrUrl === "string" ? { url: configOrUrl } : configOrUrl;
810
+ this.url = config.url.replace(/\/$/, "");
811
+ this.authStore = config.authStore ?? new MemoryAuthStore();
812
+ this.store = new QueryStore();
813
+ this.realtime = new RealtimeService(this.url, () => this.authStore.token);
814
+ this.files = new FileService(this.url);
815
+ this._versionTracker = new VersionTracker();
816
+ this.authStore.onChange((token) => {
817
+ this._versionTracker.setIdentity(
818
+ token ? VersionTracker.hashIdentity(token) : 0
819
+ );
820
+ });
821
+ }
822
+ // ---- PocketBase-compatible collection() API -----------------------------
823
+ /** Get or create a CollectionService for the given name/id. */
824
+ collection(nameOrId) {
825
+ let svc = this._collections.get(nameOrId);
826
+ if (!svc) {
827
+ svc = new CollectionService(
828
+ nameOrId,
829
+ this.url,
830
+ () => this.authStore.token,
831
+ (token, record) => this.authStore.save(token, record),
832
+ this.store,
833
+ this.realtime
834
+ );
835
+ this._collections.set(nameOrId, svc);
836
+ }
837
+ return svc;
838
+ }
839
+ // ---- State version ------------------------------------------------------
840
+ /** Current state version from the QueryStore's internal tracker. */
841
+ get version() {
842
+ return this.store.version;
843
+ }
844
+ // ---- Direct convenience API (kept for backwards compatibility) ----------
845
+ _headers() {
846
+ const h = { "Content-Type": "application/json" };
847
+ if (this.authStore.token) {
848
+ h["Authorization"] = this.authStore.token;
849
+ }
850
+ return h;
851
+ }
852
+ async _request(method, path, body) {
853
+ const res = await fetch(`${this.url}${path}`, {
854
+ method,
855
+ headers: this._headers(),
856
+ body: body !== void 0 ? JSON.stringify(body) : void 0
857
+ });
858
+ if (!res.ok) {
859
+ const text = await res.text();
860
+ let detail;
861
+ try {
862
+ detail = JSON.parse(text);
863
+ } catch {
864
+ detail = text;
865
+ }
866
+ throw new BaseClientError(res.status, detail);
867
+ }
868
+ if (res.status === 204) return void 0;
869
+ return res.json();
870
+ }
871
+ async list(collection, options) {
872
+ const params = new URLSearchParams();
873
+ if (options?.filter) params.set("filter", options.filter);
874
+ if (options?.sort) params.set("sort", options.sort);
875
+ if (options?.expand) params.set("expand", options.expand);
876
+ if (options?.fields) params.set("fields", options.fields);
877
+ if (options?.page) params.set("page", String(options.page));
878
+ if (options?.perPage) params.set("perPage", String(options.perPage));
879
+ const qs = params.toString();
880
+ const path = `/api/collections/${encodeURIComponent(collection)}/records${qs ? "?" + qs : ""}`;
881
+ const result = await this._request("GET", path);
882
+ this.store.setQuery(collection, options?.filter ?? "", result.items);
883
+ return result;
884
+ }
885
+ async getOne(collection, id, options) {
886
+ const params = new URLSearchParams();
887
+ if (options?.expand) params.set("expand", options.expand);
888
+ if (options?.fields) params.set("fields", options.fields);
889
+ const qs = params.toString();
890
+ const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}${qs ? "?" + qs : ""}`;
891
+ return this._request("GET", path);
892
+ }
893
+ async create(collection, data) {
894
+ const path = `/api/collections/${encodeURIComponent(collection)}/records`;
895
+ const record = await this._request("POST", path, data);
896
+ this.store.applyServerUpdate(collection, "create", record);
897
+ return record;
898
+ }
899
+ async update(collection, id, data) {
900
+ const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`;
901
+ const record = await this._request("PATCH", path, data);
902
+ this.store.applyServerUpdate(collection, "update", record);
903
+ return record;
904
+ }
905
+ async delete(collection, id) {
906
+ const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`;
907
+ await this._request("DELETE", path);
908
+ this.store.applyServerUpdate(collection, "delete", { id });
909
+ }
910
+ // ---- Auth (direct convenience) ------------------------------------------
911
+ async signInWithPassword(collection, identity, password) {
912
+ const path = `/api/collections/${encodeURIComponent(collection)}/auth-with-password`;
913
+ const result = await this._request("POST", path, {
914
+ identity,
915
+ password
916
+ });
917
+ this.authStore.save(result.token, result.record);
918
+ return result;
919
+ }
920
+ async signUp(collection, data) {
921
+ return this.create(collection, data);
922
+ }
923
+ async refreshAuth(collection) {
924
+ const path = `/api/collections/${encodeURIComponent(collection)}/auth-refresh`;
925
+ const result = await this._request("POST", path);
926
+ this.authStore.save(result.token, result.record);
927
+ return result;
928
+ }
929
+ signOut() {
930
+ this.authStore.clear();
931
+ }
932
+ // ---- Raw request --------------------------------------------------------
933
+ /**
934
+ * Send a raw request to the Base API.
935
+ * Convenience for endpoints not covered by CollectionService.
936
+ */
937
+ async send(path, options = {}) {
938
+ const method = options.method ?? "GET";
939
+ let url = `${this.url}${path}`;
940
+ if (options.query) {
941
+ const params = new URLSearchParams(options.query);
942
+ url += "?" + params.toString();
943
+ }
944
+ const headers = { ...options.headers };
945
+ if (this.authStore.token) {
946
+ headers["Authorization"] = this.authStore.token;
947
+ }
948
+ const response = await fetch(url, {
949
+ method,
950
+ headers,
951
+ body: options.body,
952
+ signal: options.signal
953
+ });
954
+ if (!response.ok) {
955
+ const data = await response.json().catch(() => ({}));
956
+ throw new BaseClientError(
957
+ response.status,
958
+ data
959
+ );
960
+ }
961
+ if (response.status === 204) return void 0;
962
+ return response.json();
963
+ }
964
+ // ---- Health check -------------------------------------------------------
965
+ async health() {
966
+ return this.send("/api/health");
967
+ }
968
+ // ---- Realtime convenience -----------------------------------------------
969
+ /**
970
+ * Subscribe to realtime events for a collection topic.
971
+ * Also wires events into the QueryStore automatically.
972
+ */
973
+ subscribeAndSync(collection, topic = "*", callback) {
974
+ return this.realtime.subscribe(collection, topic, (event) => {
975
+ this.store.applyServerUpdate(collection, event.action, event.record);
976
+ callback?.(event);
977
+ });
978
+ }
979
+ // ---- Cleanup ------------------------------------------------------------
980
+ /** Disconnect realtime and clear caches. */
981
+ disconnect() {
982
+ this.realtime.disconnect();
983
+ }
984
+ };
985
+ var BaseClientError = class extends Error {
986
+ constructor(status, detail) {
987
+ super(`BaseClient error ${status}`);
988
+ this.name = "BaseClientError";
989
+ this.status = status;
990
+ this.detail = detail;
991
+ }
992
+ };
993
+
994
+ export { BaseClient, BaseClientError, ClientResponseError, CollectionService, FileService, MemoryAuthStore, QueryStore, RealtimeService, VersionTracker };
995
+ //# sourceMappingURL=chunk-LBAV5X5P.js.map
996
+ //# sourceMappingURL=chunk-LBAV5X5P.js.map