@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.
- package/dist/chunk-5NHFZRMO.js +1011 -0
- package/dist/chunk-5NHFZRMO.js.map +1 -0
- package/dist/chunk-LBAV5X5P.js +996 -0
- package/dist/chunk-LBAV5X5P.js.map +1 -0
- package/dist/compat/index.d.ts +1 -0
- package/dist/compat/index.js +3 -0
- package/dist/compat/index.js.map +1 -0
- package/dist/core/index.d.ts +368 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/crdt/index.d.ts +372 -0
- package/dist/crdt/index.js +3 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/react/index.d.ts +144 -0
- package/dist/react/index.js +283 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +71 -0
- package/src/compat/index.ts +24 -0
- package/src/core/client.ts +432 -0
- package/src/core/collection.ts +474 -0
- package/src/core/index.ts +37 -0
- package/src/core/realtime.ts +303 -0
- package/src/core/state.ts +112 -0
- package/src/core/store.ts +241 -0
- package/src/crdt/clock.ts +78 -0
- package/src/crdt/counter.ts +130 -0
- package/src/crdt/document.ts +194 -0
- package/src/crdt/index.ts +56 -0
- package/src/crdt/operations.ts +101 -0
- package/src/crdt/register.ts +106 -0
- package/src/crdt/set.ts +172 -0
- package/src/crdt/sync.ts +412 -0
- package/src/crdt/text.ts +274 -0
- package/src/react/context.tsx +87 -0
- package/src/react/hooks.ts +489 -0
- package/src/react/index.ts +56 -0
|
@@ -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
|
+
}
|