@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,474 @@
1
+ /**
2
+ * CollectionService -- typed CRUD + auth + realtime for a single collection.
3
+ *
4
+ * API-compatible with PocketBase JS SDK's RecordService, extended with
5
+ * reactive features (subscribe/unsubscribe, optimistic writes).
6
+ */
7
+
8
+ import type { BaseRecord } from './state.js'
9
+ import type { QueryStore } from './store.js'
10
+ import type { RealtimeService, RealtimeCallback } from './realtime.js'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface ListResult<T = BaseRecord> {
17
+ page: number
18
+ perPage: number
19
+ totalItems: number
20
+ totalPages: number
21
+ items: T[]
22
+ }
23
+
24
+ export interface RecordQueryOptions {
25
+ filter?: string
26
+ sort?: string
27
+ expand?: string
28
+ fields?: string
29
+ headers?: Record<string, string>
30
+ /** Extra query params merged into the URL. */
31
+ query?: Record<string, string>
32
+ /** Request-scoped AbortSignal. */
33
+ signal?: AbortSignal
34
+ }
35
+
36
+ export interface RecordFullListOptions extends RecordQueryOptions {
37
+ /** Batch size for pagination (default 200). */
38
+ batch?: number
39
+ }
40
+
41
+ export interface FileOptions {
42
+ thumb?: string
43
+ token?: string
44
+ }
45
+
46
+ export interface AuthResponse<T = BaseRecord> {
47
+ token: string
48
+ record: T
49
+ }
50
+
51
+ export interface OAuth2Options {
52
+ provider: string
53
+ code: string
54
+ codeVerifier: string
55
+ redirectUrl: string
56
+ createData?: Record<string, unknown>
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // CollectionService
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export class CollectionService {
64
+ readonly collectionIdOrName: string
65
+
66
+ private readonly _baseUrl: string
67
+ private readonly _getToken: () => string
68
+ private readonly _setAuth: (token: string, record: BaseRecord) => void
69
+ private readonly _store: QueryStore
70
+ private readonly _realtime: RealtimeService
71
+
72
+ constructor(
73
+ collectionIdOrName: string,
74
+ baseUrl: string,
75
+ getToken: () => string,
76
+ setAuth: (token: string, record: BaseRecord) => void,
77
+ store: QueryStore,
78
+ realtime: RealtimeService,
79
+ ) {
80
+ this.collectionIdOrName = collectionIdOrName
81
+ this._baseUrl = baseUrl.replace(/\/$/, '')
82
+ this._getToken = getToken
83
+ this._setAuth = setAuth
84
+ this._store = store
85
+ this._realtime = realtime
86
+ }
87
+
88
+ // ---- CRUD ---------------------------------------------------------------
89
+
90
+ async getList<T extends BaseRecord = BaseRecord>(
91
+ page = 1,
92
+ perPage = 30,
93
+ options?: RecordQueryOptions,
94
+ ): Promise<ListResult<T>> {
95
+ const params = new URLSearchParams()
96
+ params.set('page', String(page))
97
+ params.set('perPage', String(perPage))
98
+ this._applyOptions(params, options)
99
+
100
+ const result = await this._request<ListResult<T>>(
101
+ 'GET',
102
+ `${this._collectionPath()}/records?${params}`,
103
+ undefined,
104
+ options,
105
+ )
106
+
107
+ // Cache in store.
108
+ const cacheFilter = options?.filter ?? ''
109
+ this._store.setQuery(
110
+ this.collectionIdOrName,
111
+ cacheFilter,
112
+ result.items as unknown as BaseRecord[],
113
+ )
114
+
115
+ return result
116
+ }
117
+
118
+ async getFullList<T extends BaseRecord = BaseRecord>(
119
+ options?: RecordFullListOptions,
120
+ ): Promise<T[]> {
121
+ const batch = options?.batch ?? 200
122
+ let page = 1
123
+ let all: T[] = []
124
+
125
+ // eslint-disable-next-line no-constant-condition
126
+ while (true) {
127
+ const result = await this.getList<T>(page, batch, options)
128
+ all = all.concat(result.items)
129
+ if (all.length >= result.totalItems || result.items.length < batch) {
130
+ break
131
+ }
132
+ page++
133
+ }
134
+
135
+ return all
136
+ }
137
+
138
+ async getOne<T extends BaseRecord = BaseRecord>(
139
+ id: string,
140
+ options?: RecordQueryOptions,
141
+ ): Promise<T> {
142
+ const params = new URLSearchParams()
143
+ this._applyOptions(params, options)
144
+ const qs = params.toString()
145
+ const path = `${this._collectionPath()}/records/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`
146
+ return this._request<T>('GET', path, undefined, options)
147
+ }
148
+
149
+ async getFirstListItem<T extends BaseRecord = BaseRecord>(
150
+ filter: string,
151
+ options?: RecordQueryOptions,
152
+ ): Promise<T> {
153
+ const opts = { ...options, filter }
154
+ const result = await this.getList<T>(1, 1, opts)
155
+ if (result.items.length === 0) {
156
+ throw new ClientResponseError({
157
+ url: this._baseUrl,
158
+ status: 404,
159
+ data: { message: 'The requested resource wasn\'t found.' },
160
+ })
161
+ }
162
+ return result.items[0]
163
+ }
164
+
165
+ async create<T extends BaseRecord = BaseRecord>(
166
+ data: Record<string, unknown> | FormData,
167
+ options?: RecordQueryOptions,
168
+ ): Promise<T> {
169
+ const params = new URLSearchParams()
170
+ this._applyOptions(params, options)
171
+ const qs = params.toString()
172
+ const path = `${this._collectionPath()}/records${qs ? '?' + qs : ''}`
173
+
174
+ // Optimistic: generate temp id.
175
+ let mutationId: string | undefined
176
+ if (!(data instanceof FormData) && typeof data === 'object') {
177
+ const tempId = `__temp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
178
+ const optimistic: BaseRecord = {
179
+ id: tempId,
180
+ collectionName: this.collectionIdOrName,
181
+ ...(data as Record<string, unknown>),
182
+ }
183
+ mutationId = this._store.optimisticSet(this.collectionIdOrName, optimistic)
184
+ }
185
+
186
+ try {
187
+ const body = data instanceof FormData ? data : JSON.stringify(data)
188
+ const contentType = data instanceof FormData ? undefined : 'application/json'
189
+ const record = await this._request<T>('POST', path, body, options, contentType)
190
+
191
+ // Replace optimistic entry with real server record.
192
+ if (mutationId) {
193
+ this._store.rollbackOptimistic(mutationId)
194
+ }
195
+ this._store.applyServerUpdate(this.collectionIdOrName, 'create', record as unknown as BaseRecord)
196
+ return record
197
+ } catch (err) {
198
+ if (mutationId) {
199
+ this._store.rollbackOptimistic(mutationId)
200
+ }
201
+ throw err
202
+ }
203
+ }
204
+
205
+ async update<T extends BaseRecord = BaseRecord>(
206
+ id: string,
207
+ data: Record<string, unknown> | FormData,
208
+ options?: RecordQueryOptions,
209
+ ): Promise<T> {
210
+ const params = new URLSearchParams()
211
+ this._applyOptions(params, options)
212
+ const qs = params.toString()
213
+ const path = `${this._collectionPath()}/records/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`
214
+
215
+ // Optimistic update.
216
+ let mutationId: string | undefined
217
+ if (!(data instanceof FormData) && typeof data === 'object') {
218
+ const optimistic: BaseRecord = {
219
+ id,
220
+ collectionName: this.collectionIdOrName,
221
+ ...(data as Record<string, unknown>),
222
+ }
223
+ mutationId = this._store.optimisticSet(this.collectionIdOrName, optimistic)
224
+ }
225
+
226
+ try {
227
+ const body = data instanceof FormData ? data : JSON.stringify(data)
228
+ const contentType = data instanceof FormData ? undefined : 'application/json'
229
+ const record = await this._request<T>('PATCH', path, body, options, contentType)
230
+
231
+ if (mutationId) {
232
+ this._store.rollbackOptimistic(mutationId)
233
+ }
234
+ this._store.applyServerUpdate(this.collectionIdOrName, 'update', record as unknown as BaseRecord)
235
+ return record
236
+ } catch (err) {
237
+ if (mutationId) {
238
+ this._store.rollbackOptimistic(mutationId)
239
+ }
240
+ throw err
241
+ }
242
+ }
243
+
244
+ async delete(id: string, options?: RecordQueryOptions): Promise<boolean> {
245
+ const params = new URLSearchParams()
246
+ this._applyOptions(params, options)
247
+ const qs = params.toString()
248
+ const path = `${this._collectionPath()}/records/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`
249
+
250
+ // Optimistic delete.
251
+ const mutationId = this._store.optimisticDelete(this.collectionIdOrName, id)
252
+
253
+ try {
254
+ await this._request<void>('DELETE', path, undefined, options)
255
+ this._store.rollbackOptimistic(mutationId)
256
+ this._store.applyServerUpdate(this.collectionIdOrName, 'delete', { id } as BaseRecord)
257
+ return true
258
+ } catch (err) {
259
+ this._store.rollbackOptimistic(mutationId)
260
+ throw err
261
+ }
262
+ }
263
+
264
+ // ---- Realtime -----------------------------------------------------------
265
+
266
+ /**
267
+ * Subscribe to realtime events for this collection.
268
+ * `topic` is "*" for all changes or a specific record id.
269
+ */
270
+ subscribe(topic: string, callback: RealtimeCallback): () => void {
271
+ return this._realtime.subscribe(this.collectionIdOrName, topic, callback)
272
+ }
273
+
274
+ /** Unsubscribe from a specific topic or all topics for this collection. */
275
+ unsubscribe(topic?: string): void {
276
+ this._realtime.unsubscribe(
277
+ topic ? `${this.collectionIdOrName}/${topic}` : this.collectionIdOrName,
278
+ )
279
+ }
280
+
281
+ // ---- Auth methods (for auth collections) --------------------------------
282
+
283
+ async authWithPassword<T extends BaseRecord = BaseRecord>(
284
+ identity: string,
285
+ password: string,
286
+ options?: RecordQueryOptions,
287
+ ): Promise<AuthResponse<T>> {
288
+ const params = new URLSearchParams()
289
+ this._applyOptions(params, options)
290
+ const qs = params.toString()
291
+ const path = `${this._collectionPath()}/auth-with-password${qs ? '?' + qs : ''}`
292
+
293
+ const result = await this._request<AuthResponse<T>>(
294
+ 'POST',
295
+ path,
296
+ JSON.stringify({ identity, password }),
297
+ options,
298
+ 'application/json',
299
+ )
300
+
301
+ this._setAuth(result.token, result.record as unknown as BaseRecord)
302
+ return result
303
+ }
304
+
305
+ async authWithOAuth2<T extends BaseRecord = BaseRecord>(
306
+ oauthOptions: OAuth2Options,
307
+ options?: RecordQueryOptions,
308
+ ): Promise<AuthResponse<T>> {
309
+ const params = new URLSearchParams()
310
+ this._applyOptions(params, options)
311
+ const qs = params.toString()
312
+ const path = `${this._collectionPath()}/auth-with-oauth2${qs ? '?' + qs : ''}`
313
+
314
+ const result = await this._request<AuthResponse<T>>(
315
+ 'POST',
316
+ path,
317
+ JSON.stringify(oauthOptions),
318
+ options,
319
+ 'application/json',
320
+ )
321
+
322
+ this._setAuth(result.token, result.record as unknown as BaseRecord)
323
+ return result
324
+ }
325
+
326
+ async requestVerification(email: string, options?: RecordQueryOptions): Promise<boolean> {
327
+ const path = `${this._collectionPath()}/request-verification`
328
+ await this._request<void>(
329
+ 'POST',
330
+ path,
331
+ JSON.stringify({ email }),
332
+ options,
333
+ 'application/json',
334
+ )
335
+ return true
336
+ }
337
+
338
+ async confirmVerification(token: string, options?: RecordQueryOptions): Promise<boolean> {
339
+ const path = `${this._collectionPath()}/confirm-verification`
340
+ await this._request<void>(
341
+ 'POST',
342
+ path,
343
+ JSON.stringify({ token }),
344
+ options,
345
+ 'application/json',
346
+ )
347
+ return true
348
+ }
349
+
350
+ async requestPasswordReset(email: string, options?: RecordQueryOptions): Promise<boolean> {
351
+ const path = `${this._collectionPath()}/request-password-reset`
352
+ await this._request<void>(
353
+ 'POST',
354
+ path,
355
+ JSON.stringify({ email }),
356
+ options,
357
+ 'application/json',
358
+ )
359
+ return true
360
+ }
361
+
362
+ async confirmPasswordReset(
363
+ token: string,
364
+ password: string,
365
+ passwordConfirm: string,
366
+ options?: RecordQueryOptions,
367
+ ): Promise<boolean> {
368
+ const path = `${this._collectionPath()}/confirm-password-reset`
369
+ await this._request<void>(
370
+ 'POST',
371
+ path,
372
+ JSON.stringify({ token, password, passwordConfirm }),
373
+ options,
374
+ 'application/json',
375
+ )
376
+ return true
377
+ }
378
+
379
+ // ---- Internal -----------------------------------------------------------
380
+
381
+ private _collectionPath(): string {
382
+ return `/api/collections/${encodeURIComponent(this.collectionIdOrName)}`
383
+ }
384
+
385
+ private _applyOptions(params: URLSearchParams, options?: RecordQueryOptions): void {
386
+ if (!options) return
387
+ if (options.filter) params.set('filter', options.filter)
388
+ if (options.sort) params.set('sort', options.sort)
389
+ if (options.expand) params.set('expand', options.expand)
390
+ if (options.fields) params.set('fields', options.fields)
391
+ if (options.query) {
392
+ for (const [k, v] of Object.entries(options.query)) {
393
+ params.set(k, v)
394
+ }
395
+ }
396
+ }
397
+
398
+ private async _request<T>(
399
+ method: string,
400
+ path: string,
401
+ body?: string | FormData,
402
+ options?: RecordQueryOptions,
403
+ contentType?: string,
404
+ ): Promise<T> {
405
+ const url = `${this._baseUrl}${path}`
406
+ const token = this._getToken()
407
+
408
+ const headers: Record<string, string> = {
409
+ ...(options?.headers ?? {}),
410
+ }
411
+ if (token) {
412
+ headers['Authorization'] = token
413
+ }
414
+ if (contentType) {
415
+ headers['Content-Type'] = contentType
416
+ }
417
+
418
+ const response = await fetch(url, {
419
+ method,
420
+ headers,
421
+ body: body ?? undefined,
422
+ signal: options?.signal,
423
+ })
424
+
425
+ if (!response.ok) {
426
+ const data = await response.json().catch(() => ({}))
427
+ throw new ClientResponseError({
428
+ url,
429
+ status: response.status,
430
+ data,
431
+ })
432
+ }
433
+
434
+ // DELETE returns 204 with no body.
435
+ if (response.status === 204) {
436
+ return undefined as T
437
+ }
438
+
439
+ return response.json() as Promise<T>
440
+ }
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // ClientResponseError
445
+ // ---------------------------------------------------------------------------
446
+
447
+ export interface ClientResponseErrorData {
448
+ url: string
449
+ status: number
450
+ data: Record<string, unknown>
451
+ }
452
+
453
+ export class ClientResponseError extends Error {
454
+ url: string
455
+ status: number
456
+ data: Record<string, unknown>
457
+ isAbort: boolean
458
+
459
+ constructor(errorData: ClientResponseErrorData) {
460
+ const message =
461
+ (errorData.data?.message as string) ??
462
+ `ClientResponseError ${errorData.status}`
463
+ super(message)
464
+ this.name = 'ClientResponseError'
465
+ this.url = errorData.url
466
+ this.status = errorData.status
467
+ this.data = errorData.data
468
+ this.isAbort = errorData.status === 0
469
+ }
470
+
471
+ toJSON(): ClientResponseErrorData {
472
+ return { url: this.url, status: this.status, data: this.data }
473
+ }
474
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @hanzoai/base -- Core entry point.
3
+ *
4
+ * Re-exports the client, collection, store, state, and realtime modules.
5
+ */
6
+
7
+ // Client
8
+ export { BaseClient, BaseClientError, MemoryAuthStore, FileService } from './client.js'
9
+ export type { AuthStore, AuthChangeCallback, ClientConfig, ListOptions, ListResult } from './client.js'
10
+
11
+ // Collection
12
+ export { CollectionService, ClientResponseError } from './collection.js'
13
+ export type {
14
+ RecordQueryOptions,
15
+ RecordFullListOptions,
16
+ FileOptions,
17
+ AuthResponse,
18
+ OAuth2Options,
19
+ ClientResponseErrorData,
20
+ } from './collection.js'
21
+
22
+ // Store
23
+ export { QueryStore } from './store.js'
24
+ export type { QueryKey, StoreCallback } from './store.js'
25
+
26
+ // State
27
+ export { VersionTracker } from './state.js'
28
+ export type { StateVersion, Modification, Transition, BaseRecord } from './state.js'
29
+
30
+ // Realtime
31
+ export { RealtimeService } from './realtime.js'
32
+ export type {
33
+ ConnectionState,
34
+ RealtimeEvent,
35
+ RealtimeCallback,
36
+ ConnectionCallback,
37
+ } from './realtime.js'