@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,432 @@
1
+ /**
2
+ * BaseClient -- main entry point for @hanzoai/base.
3
+ *
4
+ * Two API surfaces:
5
+ *
6
+ * 1. PocketBase-compatible: client.collection('posts').getList(...)
7
+ * 2. Direct (convenience): client.list('posts', { filter: '...' })
8
+ *
9
+ * Both share the same QueryStore, RealtimeService, and AuthStore.
10
+ */
11
+
12
+ import type { BaseRecord } from './state.js'
13
+ import { VersionTracker } from './state.js'
14
+ import { QueryStore } from './store.js'
15
+ import { RealtimeService, type RealtimeEvent } from './realtime.js'
16
+ import { CollectionService, type FileOptions } from './collection.js'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // AuthStore
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface AuthStore {
23
+ token: string
24
+ record: BaseRecord | null
25
+ onChange(callback: (token: string, record: BaseRecord | null) => void): () => void
26
+ save(token: string, record: BaseRecord | null): void
27
+ clear(): void
28
+ readonly isValid: boolean
29
+ }
30
+
31
+ export type AuthChangeCallback = (token: string, record: BaseRecord | null) => void
32
+
33
+ /**
34
+ * Default in-memory auth store.
35
+ * Validates JWT exp claim without external dependencies.
36
+ */
37
+ export class MemoryAuthStore implements AuthStore {
38
+ private _token = ''
39
+ private _record: BaseRecord | null = null
40
+ private _listeners = new Set<AuthChangeCallback>()
41
+
42
+ get token(): string {
43
+ return this._token
44
+ }
45
+
46
+ get record(): BaseRecord | null {
47
+ return this._record
48
+ }
49
+
50
+ get isValid(): boolean {
51
+ if (!this._token) return false
52
+ try {
53
+ const parts = this._token.split('.')
54
+ if (parts.length !== 3) return false
55
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')))
56
+ if (typeof payload.exp === 'number') {
57
+ return payload.exp > Date.now() / 1000
58
+ }
59
+ return true
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ save(token: string, record: BaseRecord | null): void {
66
+ this._token = token
67
+ this._record = record
68
+ this._notify()
69
+ }
70
+
71
+ clear(): void {
72
+ this._token = ''
73
+ this._record = null
74
+ this._notify()
75
+ }
76
+
77
+ onChange(callback: AuthChangeCallback): () => void {
78
+ this._listeners.add(callback)
79
+ return () => {
80
+ this._listeners.delete(callback)
81
+ }
82
+ }
83
+
84
+ private _notify(): void {
85
+ for (const cb of this._listeners) {
86
+ try {
87
+ cb(this._token, this._record)
88
+ } catch {
89
+ // listener errors must not break notification
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // FileService
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export class FileService {
100
+ private readonly _baseUrl: string
101
+
102
+ constructor(baseUrl: string) {
103
+ this._baseUrl = baseUrl.replace(/\/$/, '')
104
+ }
105
+
106
+ /**
107
+ * Build a full URL to a record file.
108
+ * Compatible with PocketBase's pb.files.getURL().
109
+ */
110
+ getURL(record: BaseRecord, filename: string, options?: FileOptions): string {
111
+ if (!filename || !record.id) return ''
112
+
113
+ const collectionId = (record.collectionId ?? record.collectionName ?? '') as string
114
+ const parts = [
115
+ this._baseUrl,
116
+ 'api',
117
+ 'files',
118
+ encodeURIComponent(collectionId),
119
+ encodeURIComponent(record.id),
120
+ encodeURIComponent(filename),
121
+ ]
122
+
123
+ let url = parts.join('/')
124
+
125
+ const params = new URLSearchParams()
126
+ if (options?.thumb) params.set('thumb', options.thumb)
127
+ if (options?.token) params.set('token', options.token)
128
+ const qs = params.toString()
129
+ if (qs) url += '?' + qs
130
+
131
+ return url
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // ClientConfig
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export interface ClientConfig {
140
+ /** Base URL of the Hanzo Base instance (e.g. "https://myapp.hanzo.ai"). */
141
+ url: string
142
+ /** Optional external auth store. Defaults to in-memory store. */
143
+ authStore?: AuthStore
144
+ }
145
+
146
+ export interface ListOptions {
147
+ filter?: string
148
+ sort?: string
149
+ expand?: string
150
+ fields?: string
151
+ page?: number
152
+ perPage?: number
153
+ }
154
+
155
+ export interface ListResult<T = BaseRecord> {
156
+ page: number
157
+ perPage: number
158
+ totalItems: number
159
+ totalPages: number
160
+ items: T[]
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // BaseClient
165
+ // ---------------------------------------------------------------------------
166
+
167
+ export class BaseClient {
168
+ readonly url: string
169
+ readonly authStore: AuthStore
170
+ readonly store: QueryStore
171
+ readonly realtime: RealtimeService
172
+ readonly files: FileService
173
+
174
+ private readonly _versionTracker: VersionTracker
175
+ private readonly _collections = new Map<string, CollectionService>()
176
+
177
+ /**
178
+ * Create a BaseClient.
179
+ *
180
+ * Accepts either a config object or a plain URL string for convenience:
181
+ * new BaseClient('https://myapp.hanzo.ai')
182
+ * new BaseClient({ url: 'https://myapp.hanzo.ai' })
183
+ */
184
+ constructor(configOrUrl: ClientConfig | string) {
185
+ const config: ClientConfig =
186
+ typeof configOrUrl === 'string' ? { url: configOrUrl } : configOrUrl
187
+
188
+ this.url = config.url.replace(/\/$/, '')
189
+ this.authStore = config.authStore ?? new MemoryAuthStore()
190
+ this.store = new QueryStore()
191
+ this.realtime = new RealtimeService(this.url, () => this.authStore.token)
192
+ this.files = new FileService(this.url)
193
+ this._versionTracker = new VersionTracker()
194
+
195
+ // Sync identity hash when auth changes.
196
+ this.authStore.onChange((token) => {
197
+ this._versionTracker.setIdentity(
198
+ token ? VersionTracker.hashIdentity(token) : 0,
199
+ )
200
+ })
201
+ }
202
+
203
+ // ---- PocketBase-compatible collection() API -----------------------------
204
+
205
+ /** Get or create a CollectionService for the given name/id. */
206
+ collection(nameOrId: string): CollectionService {
207
+ let svc = this._collections.get(nameOrId)
208
+ if (!svc) {
209
+ svc = new CollectionService(
210
+ nameOrId,
211
+ this.url,
212
+ () => this.authStore.token,
213
+ (token, record) => this.authStore.save(token, record),
214
+ this.store,
215
+ this.realtime,
216
+ )
217
+ this._collections.set(nameOrId, svc)
218
+ }
219
+ return svc
220
+ }
221
+
222
+ // ---- State version ------------------------------------------------------
223
+
224
+ /** Current state version from the QueryStore's internal tracker. */
225
+ get version() {
226
+ return this.store.version
227
+ }
228
+
229
+ // ---- Direct convenience API (kept for backwards compatibility) ----------
230
+
231
+ private _headers(): Record<string, string> {
232
+ const h: Record<string, string> = { 'Content-Type': 'application/json' }
233
+ if (this.authStore.token) {
234
+ h['Authorization'] = this.authStore.token
235
+ }
236
+ return h
237
+ }
238
+
239
+ private async _request<T>(method: string, path: string, body?: unknown): Promise<T> {
240
+ const res = await fetch(`${this.url}${path}`, {
241
+ method,
242
+ headers: this._headers(),
243
+ body: body !== undefined ? JSON.stringify(body) : undefined,
244
+ })
245
+
246
+ if (!res.ok) {
247
+ const text = await res.text()
248
+ let detail: unknown
249
+ try {
250
+ detail = JSON.parse(text)
251
+ } catch {
252
+ detail = text
253
+ }
254
+ throw new BaseClientError(res.status, detail)
255
+ }
256
+
257
+ if (res.status === 204) return undefined as T
258
+
259
+ return res.json() as Promise<T>
260
+ }
261
+
262
+ async list(collection: string, options?: ListOptions): Promise<ListResult> {
263
+ const params = new URLSearchParams()
264
+ if (options?.filter) params.set('filter', options.filter)
265
+ if (options?.sort) params.set('sort', options.sort)
266
+ if (options?.expand) params.set('expand', options.expand)
267
+ if (options?.fields) params.set('fields', options.fields)
268
+ if (options?.page) params.set('page', String(options.page))
269
+ if (options?.perPage) params.set('perPage', String(options.perPage))
270
+
271
+ const qs = params.toString()
272
+ const path = `/api/collections/${encodeURIComponent(collection)}/records${qs ? '?' + qs : ''}`
273
+ const result = await this._request<ListResult>('GET', path)
274
+
275
+ this.store.setQuery(collection, options?.filter ?? '', result.items)
276
+ return result
277
+ }
278
+
279
+ async getOne(collection: string, id: string, options?: Pick<ListOptions, 'expand' | 'fields'>): Promise<BaseRecord> {
280
+ const params = new URLSearchParams()
281
+ if (options?.expand) params.set('expand', options.expand)
282
+ if (options?.fields) params.set('fields', options.fields)
283
+ const qs = params.toString()
284
+ const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`
285
+ return this._request<BaseRecord>('GET', path)
286
+ }
287
+
288
+ async create(collection: string, data: Record<string, unknown>): Promise<BaseRecord> {
289
+ const path = `/api/collections/${encodeURIComponent(collection)}/records`
290
+ const record = await this._request<BaseRecord>('POST', path, data)
291
+ this.store.applyServerUpdate(collection, 'create', record)
292
+ return record
293
+ }
294
+
295
+ async update(collection: string, id: string, data: Record<string, unknown>): Promise<BaseRecord> {
296
+ const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`
297
+ const record = await this._request<BaseRecord>('PATCH', path, data)
298
+ this.store.applyServerUpdate(collection, 'update', record)
299
+ return record
300
+ }
301
+
302
+ async delete(collection: string, id: string): Promise<void> {
303
+ const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`
304
+ await this._request<void>('DELETE', path)
305
+ this.store.applyServerUpdate(collection, 'delete', { id } as BaseRecord)
306
+ }
307
+
308
+ // ---- Auth (direct convenience) ------------------------------------------
309
+
310
+ async signInWithPassword(
311
+ collection: string,
312
+ identity: string,
313
+ password: string,
314
+ ): Promise<{ token: string; record: BaseRecord }> {
315
+ const path = `/api/collections/${encodeURIComponent(collection)}/auth-with-password`
316
+ const result = await this._request<{ token: string; record: BaseRecord }>('POST', path, {
317
+ identity,
318
+ password,
319
+ })
320
+ this.authStore.save(result.token, result.record)
321
+ return result
322
+ }
323
+
324
+ async signUp(
325
+ collection: string,
326
+ data: Record<string, unknown>,
327
+ ): Promise<BaseRecord> {
328
+ return this.create(collection, data)
329
+ }
330
+
331
+ async refreshAuth(collection: string): Promise<{ token: string; record: BaseRecord }> {
332
+ const path = `/api/collections/${encodeURIComponent(collection)}/auth-refresh`
333
+ const result = await this._request<{ token: string; record: BaseRecord }>('POST', path)
334
+ this.authStore.save(result.token, result.record)
335
+ return result
336
+ }
337
+
338
+ signOut(): void {
339
+ this.authStore.clear()
340
+ }
341
+
342
+ // ---- Raw request --------------------------------------------------------
343
+
344
+ /**
345
+ * Send a raw request to the Base API.
346
+ * Convenience for endpoints not covered by CollectionService.
347
+ */
348
+ async send<T = unknown>(
349
+ path: string,
350
+ options: {
351
+ method?: string
352
+ headers?: Record<string, string>
353
+ body?: string | FormData
354
+ query?: Record<string, string>
355
+ signal?: AbortSignal
356
+ } = {},
357
+ ): Promise<T> {
358
+ const method = options.method ?? 'GET'
359
+ let url = `${this.url}${path}`
360
+
361
+ if (options.query) {
362
+ const params = new URLSearchParams(options.query)
363
+ url += '?' + params.toString()
364
+ }
365
+
366
+ const headers: Record<string, string> = { ...options.headers }
367
+ if (this.authStore.token) {
368
+ headers['Authorization'] = this.authStore.token
369
+ }
370
+
371
+ const response = await fetch(url, {
372
+ method,
373
+ headers,
374
+ body: options.body,
375
+ signal: options.signal,
376
+ })
377
+
378
+ if (!response.ok) {
379
+ const data = await response.json().catch(() => ({}))
380
+ throw new BaseClientError(
381
+ response.status,
382
+ data,
383
+ )
384
+ }
385
+
386
+ if (response.status === 204) return undefined as T
387
+
388
+ return response.json() as Promise<T>
389
+ }
390
+
391
+ // ---- Health check -------------------------------------------------------
392
+
393
+ async health(): Promise<{ code: number; message: string }> {
394
+ return this.send('/api/health')
395
+ }
396
+
397
+ // ---- Realtime convenience -----------------------------------------------
398
+
399
+ /**
400
+ * Subscribe to realtime events for a collection topic.
401
+ * Also wires events into the QueryStore automatically.
402
+ */
403
+ subscribeAndSync(collection: string, topic = '*', callback?: (e: RealtimeEvent) => void): () => void {
404
+ return this.realtime.subscribe(collection, topic, (event) => {
405
+ this.store.applyServerUpdate(collection, event.action, event.record)
406
+ callback?.(event)
407
+ })
408
+ }
409
+
410
+ // ---- Cleanup ------------------------------------------------------------
411
+
412
+ /** Disconnect realtime and clear caches. */
413
+ disconnect(): void {
414
+ this.realtime.disconnect()
415
+ }
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Error
420
+ // ---------------------------------------------------------------------------
421
+
422
+ export class BaseClientError extends Error {
423
+ readonly status: number
424
+ readonly detail: unknown
425
+
426
+ constructor(status: number, detail: unknown) {
427
+ super(`BaseClient error ${status}`)
428
+ this.name = 'BaseClientError'
429
+ this.status = status
430
+ this.detail = detail
431
+ }
432
+ }