lazypock 0.1.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/src/http.ts ADDED
@@ -0,0 +1,195 @@
1
+ // ── HTTP Client ─────────────────────────────────────────
2
+ // Only relies on globalThis.fetch — works in browser, React Native, and Node 18+
3
+
4
+ import { ApiError, type Method, type RequestOptions } from "./types";
5
+ import type { AuthStore } from "./auth";
6
+
7
+ /**
8
+ * Low-level HTTP client wrapping `fetch` with automatic auth token injection.
9
+ * Only relies on `globalThis.fetch` — works in browser, React Native, and Node 18+.
10
+ */
11
+ export class HttpClient {
12
+ private baseUrl: string;
13
+ private authStore: AuthStore;
14
+ private defaultFetch: typeof globalThis.fetch;
15
+
16
+ /**
17
+ * @param baseUrl The API base URL (e.g. `http://localhost:4000/api`). Trailing slash stripped.
18
+ * @param authStore The auth store providing the token for Authorization headers.
19
+ */
20
+ constructor(baseUrl: string, authStore: AuthStore) {
21
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
22
+ this.authStore = authStore;
23
+ this.defaultFetch = globalThis.fetch.bind(globalThis);
24
+ }
25
+
26
+ private async refreshAuth(): Promise<{
27
+ token: string;
28
+ record: Record<string, unknown>;
29
+ } | null> {
30
+ const collection = this.authStore.collectionName;
31
+ if (!collection) return null;
32
+ try {
33
+ const url =
34
+ this.baseUrl + "/" + encodeURIComponent(collection) + "/auth-refresh";
35
+ const headers: Record<string, string> = {
36
+ "Content-Type": "application/json",
37
+ };
38
+ if (this.authStore.token) {
39
+ headers["Authorization"] = "Bearer " + this.authStore.token;
40
+ }
41
+ const res = await this.defaultFetch(url, {
42
+ method: "POST",
43
+ headers,
44
+ });
45
+ if (!res.ok) {
46
+ this.authStore.clear();
47
+ return null;
48
+ }
49
+ const data = (await res.json()) as Record<string, unknown>;
50
+ if (data && typeof data.token === "string") {
51
+ this.authStore.set(
52
+ data.token,
53
+ (data.record as Record<string, unknown> as any) ?? null,
54
+ );
55
+ return data as { token: string; record: Record<string, unknown> };
56
+ }
57
+ return null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Make an HTTP request with automatic auth token injection and optional auto-refresh.
65
+ *
66
+ * @param method HTTP method.
67
+ * @param path URL path (appended to baseUrl).
68
+ * @param body JSON-serializable body, or FormData for file uploads.
69
+ * @param options Optional request options.
70
+ * @returns Parsed JSON response, or null for 204 No Content.
71
+ * @throws {ApiError} On non-2xx responses.
72
+ */
73
+ async request<T = unknown>(
74
+ method: Method,
75
+ path: string,
76
+ body?: unknown,
77
+ options?: RequestOptions,
78
+ ): Promise<T | null> {
79
+ // Auto-refresh if token is expired
80
+ if (this.authStore.isExpired && this.authStore.collectionName) {
81
+ await this.refreshAuth();
82
+ }
83
+
84
+ let url = this.baseUrl + path;
85
+ if (options?.params) {
86
+ const qs = new URLSearchParams(options.params).toString();
87
+ if (qs) {
88
+ url += (path.includes("?") ? "&" : "?") + qs;
89
+ }
90
+ }
91
+ const headers: Record<string, string> = {
92
+ ...options?.headers,
93
+ };
94
+
95
+ // Don't set Content-Type for FormData (browser sets multipart boundary)
96
+ if (!(body instanceof FormData)) {
97
+ headers["Content-Type"] = "application/json";
98
+ }
99
+
100
+ if (this.authStore.token) {
101
+ headers["Authorization"] = "Bearer " + this.authStore.token;
102
+ }
103
+
104
+ const init: RequestInit = {
105
+ method,
106
+ headers,
107
+ signal: options?.signal,
108
+ };
109
+
110
+ if (body != null && method !== "GET" && method !== "DELETE") {
111
+ if (body instanceof FormData) {
112
+ init.body = body;
113
+ } else {
114
+ init.body = JSON.stringify(body);
115
+ }
116
+ }
117
+
118
+ const fetcher = options?.fetch ?? this.defaultFetch;
119
+ const res = await fetcher(url, init);
120
+
121
+ if (res.status === 204) return null;
122
+
123
+ // Safely parse JSON — some errored responses may have empty or non-JSON bodies
124
+ let bodyText = "";
125
+ let data: Record<string, unknown> = {};
126
+ try {
127
+ bodyText = await res.text();
128
+ if (bodyText) {
129
+ data = JSON.parse(bodyText) as Record<string, unknown>;
130
+ }
131
+ } catch {
132
+ // Not JSON — keep data as empty object
133
+ }
134
+
135
+ if (!res.ok) {
136
+ throw new ApiError(
137
+ (typeof data.message === "string" ? data.message : res.statusText) ||
138
+ `Request failed with status ${res.status}`,
139
+ data,
140
+ res.status,
141
+ );
142
+ }
143
+
144
+ return data as T;
145
+ }
146
+
147
+ /**
148
+ * HTTP GET.
149
+ * @param path URL path.
150
+ * @param options Optional request options.
151
+ */
152
+ get<T = unknown>(path: string, options?: RequestOptions): Promise<T | null> {
153
+ return this.request<T>("GET", path, undefined, options);
154
+ }
155
+
156
+ /**
157
+ * HTTP POST.
158
+ * @param path URL path.
159
+ * @param body Optional request body.
160
+ * @param options Optional request options.
161
+ */
162
+ post<T = unknown>(
163
+ path: string,
164
+ body?: unknown,
165
+ options?: RequestOptions,
166
+ ): Promise<T | null> {
167
+ return this.request<T>("POST", path, body, options);
168
+ }
169
+
170
+ /**
171
+ * HTTP PATCH.
172
+ * @param path URL path.
173
+ * @param body Optional request body.
174
+ * @param options Optional request options.
175
+ */
176
+ patch<T = unknown>(
177
+ path: string,
178
+ body?: unknown,
179
+ options?: RequestOptions,
180
+ ): Promise<T | null> {
181
+ return this.request<T>("PATCH", path, body, options);
182
+ }
183
+
184
+ /**
185
+ * HTTP DELETE.
186
+ * @param path URL path.
187
+ * @param options Optional request options.
188
+ */
189
+ delete<T = unknown>(
190
+ path: string,
191
+ options?: RequestOptions,
192
+ ): Promise<T | null> {
193
+ return this.request<T>("DELETE", path, undefined, options);
194
+ }
195
+ }
package/src/index.ts ADDED
@@ -0,0 +1,423 @@
1
+ // ── Lazypock SDK — Root Client ─────────────────────────
2
+ // Usage:
3
+ // const client = new LazypockClient({ baseUrl: 'http://localhost:4000/api' });
4
+ // await client.authStore.init();
5
+ // await client.login('admin@example.com', 'password');
6
+ // const records = await client.collection('articles').list();
7
+
8
+ import { HttpClient } from "./http";
9
+ import {
10
+ AuthStore,
11
+ memoryStorage,
12
+ type StorageAdapter,
13
+ type AuthModel,
14
+ } from "./auth";
15
+ import { CollectionService } from "./collection";
16
+ import {
17
+ ApiError,
18
+ type ApiRecord,
19
+ type ListResult,
20
+ type RequestOptions,
21
+ } from "./types";
22
+ import { RealtimeService, wsUrlFromBaseUrl } from "./realtime";
23
+ import { FilesService, getFileUrl, type FileRecord } from "./files";
24
+
25
+ export {
26
+ AuthStore,
27
+ ApiError,
28
+ RealtimeService,
29
+ wsUrlFromBaseUrl,
30
+ FilesService,
31
+ getFileUrl,
32
+ };
33
+ export type {
34
+ StorageAdapter,
35
+ AuthModel,
36
+ ApiRecord,
37
+ ListResult,
38
+ RequestOptions,
39
+ FileRecord,
40
+ };
41
+
42
+ /** Options for constructing a {@link LazypockClient}. */
43
+ export interface LazypockClientOptions {
44
+ /** API base URL (e.g. 'http://localhost:4000/api') */
45
+ baseUrl: string;
46
+ /** Custom storage adapter (default: localStorage fallback) */
47
+ storage?: StorageAdapter;
48
+ /** Explicit auth store instance (for sharing across modules) */
49
+ authStore?: AuthStore;
50
+ /** Real-time service for Phoenix Channel WebSocket subscriptions */
51
+ realtime?: RealtimeService;
52
+ }
53
+
54
+ /**
55
+ * Lazypock API client.
56
+ *
57
+ * Provides methods for authentication, CRUD operations on dynamic collections,
58
+ * file management, and real-time subscriptions.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const client = new LazypockClient({ baseUrl: 'http://localhost:4000/api' });
63
+ * await client.login('admin@example.com', 'password');
64
+ * const posts = await client.collection('posts').list();
65
+ * ```
66
+ */
67
+ export class LazypockClient {
68
+ readonly http: HttpClient;
69
+ readonly authStore: AuthStore;
70
+ readonly realtime: RealtimeService;
71
+ readonly files: FilesService;
72
+ private collectionCache = new Map<string, CollectionService>();
73
+
74
+ /**
75
+ * Create a new Lazypock client.
76
+ * @param options Configuration options.
77
+ */
78
+ constructor(options: LazypockClientOptions) {
79
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
80
+ this.authStore =
81
+ options.authStore ?? new AuthStore(options.storage ?? memoryStorage);
82
+ this.http = new HttpClient(baseUrl, this.authStore);
83
+ this.realtime = options.realtime ?? new RealtimeService();
84
+ this.files = new FilesService(this.http);
85
+ }
86
+
87
+ /**
88
+ * Get or create a typed service for the given collection.
89
+ * Services are cached after first access.
90
+ *
91
+ * @param name The collection name.
92
+ * @returns A {@link CollectionService} instance.
93
+ */
94
+ collection(name: string): CollectionService {
95
+ let svc = this.collectionCache.get(name);
96
+ if (!svc) {
97
+ svc = new CollectionService(this.http, name, this.authStore);
98
+ this.collectionCache.set(name, svc);
99
+ }
100
+ return svc as unknown as CollectionService;
101
+ }
102
+
103
+ // ── Auth ──
104
+
105
+ /** Check whether any superuser exists (for login vs setup screen routing). */
106
+ async checkSuperuser(): Promise<{ has_superuser: boolean } | null> {
107
+ return this.http.get<{ has_superuser: boolean }>("/superusers/check");
108
+ }
109
+
110
+ /**
111
+ * Create the initial superuser account.
112
+ * Only works when no superuser exists yet.
113
+ * Stores the returned token in the auth store.
114
+ * @param email Superuser email.
115
+ * @param password Superuser password (min 8 chars).
116
+ */
117
+ async setup(
118
+ email: string,
119
+ password: string,
120
+ ): Promise<({ token: string } & Record<string, unknown>) | null> {
121
+ const data = await this.http.post<
122
+ { token: string } & Record<string, unknown>
123
+ >("/superusers/setup", { email, password });
124
+ if (data) {
125
+ this.authStore.setCollectionName(null);
126
+ this.authStore.set(data.token, null);
127
+ }
128
+ return data;
129
+ }
130
+
131
+ /**
132
+ * Authenticate as a superuser or auth collection user.
133
+ *
134
+ * When `collection` is provided, authenticates against
135
+ * `/{collection}/auth-with-password`. Otherwise logs in as superuser.
136
+ * Stores the returned token in the auth store.
137
+ *
138
+ * @param email User email or identity.
139
+ * @param password User password.
140
+ * @param collection Optional auth collection name.
141
+ */
142
+ async login(
143
+ email: string,
144
+ password: string,
145
+ collection?: string,
146
+ ): Promise<({ token: string } & Record<string, unknown>) | null> {
147
+ let data;
148
+ if (collection) {
149
+ data = await this.http.post<
150
+ { token: string; record: Record<string, unknown> } & Record<
151
+ string,
152
+ unknown
153
+ >
154
+ >("/" + encodeURIComponent(collection) + "/auth-with-password", {
155
+ identity: email,
156
+ password,
157
+ });
158
+ if (data && data.record) {
159
+ this.authStore.setCollectionName(collection);
160
+ this.authStore.set(data.token, data.record as unknown as AuthModel);
161
+ }
162
+ } else {
163
+ data = await this.http.post<{ token: string } & Record<string, unknown>>(
164
+ "/superusers/login",
165
+ { email, password },
166
+ );
167
+ if (data) {
168
+ this.authStore.setCollectionName(null);
169
+ this.authStore.set(data.token, null);
170
+ }
171
+ }
172
+ return data;
173
+ }
174
+
175
+ /** Fetch the current superuser profile and refresh the auth model. */
176
+ async me<T = ApiRecord>(options?: RequestOptions): Promise<T | null> {
177
+ const data = await this.http.get<T>("/superusers/me", options);
178
+ if (data) {
179
+ // Update the auth model with fresh data
180
+ this.authStore.set(this.authStore.token, data as unknown as AuthModel);
181
+ }
182
+ return data;
183
+ }
184
+
185
+ /**
186
+ * Authenticate against an auth collection with email/password.
187
+ * Stores the returned token and user record in the auth store.
188
+ *
189
+ * @param collection The auth collection name.
190
+ * @param identity Email or username.
191
+ * @param password Password.
192
+ * @param options Optional request options.
193
+ */
194
+ async authWithPassword(
195
+ collection: string,
196
+ identity: string,
197
+ password: string,
198
+ options?: RequestOptions,
199
+ ): Promise<
200
+ ({ token: string; record: ApiRecord } & Record<string, unknown>) | null
201
+ > {
202
+ const data = await this.http.post<
203
+ { token: string; record: ApiRecord } & Record<string, unknown>
204
+ >(
205
+ "/" + encodeURIComponent(collection) + "/auth-with-password",
206
+ { identity, password },
207
+ options,
208
+ );
209
+ if (data) {
210
+ this.authStore.setCollectionName(collection);
211
+ this.authStore.set(data.token, data.record as unknown as AuthModel);
212
+ }
213
+ return data;
214
+ }
215
+
216
+ /**
217
+ * Refresh an auth collection token.
218
+ * Uses the currently stored auth token.
219
+ *
220
+ * @param collection The auth collection name.
221
+ * @param options Optional request options.
222
+ */
223
+ async authRefresh(
224
+ collection: string,
225
+ options?: RequestOptions,
226
+ ): Promise<
227
+ ({ token: string; record: ApiRecord } & Record<string, unknown>) | null
228
+ > {
229
+ const data = await this.http.post<
230
+ { token: string; record: ApiRecord } & Record<string, unknown>
231
+ >(
232
+ "/" + encodeURIComponent(collection) + "/auth-refresh",
233
+ undefined,
234
+ options,
235
+ );
236
+ if (data) {
237
+ this.authStore.setCollectionName(collection);
238
+ this.authStore.set(data.token, data.record as unknown as AuthModel);
239
+ }
240
+ return data;
241
+ }
242
+
243
+ /** Clear the current auth state and remove persisted tokens. */
244
+ logout(): void {
245
+ this.authStore.clear();
246
+ }
247
+
248
+ // ── Health ──
249
+
250
+ /** Ping the API health endpoint. */
251
+ health(options?: RequestOptions): Promise<Record<string, unknown> | null> {
252
+ return this.http.get<Record<string, unknown>>("/health", options);
253
+ }
254
+
255
+ // ── Collection Management (admin) ──
256
+
257
+ /**
258
+ * List all collections (admin).
259
+ * @param q URL query string (e.g. `page=1&perPage=200`).
260
+ * @param options Optional request options.
261
+ */
262
+ listCollections(
263
+ q?: string,
264
+ options?: RequestOptions,
265
+ ): Promise<ListResult<ApiRecord> | null> {
266
+ return this.http.get<ListResult<ApiRecord>>(
267
+ "/collections" + (q ? "?" + q : ""),
268
+ options,
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Get a single collection by ID or name.
274
+ * @param id Collection ID or name.
275
+ * @param options Optional request options.
276
+ */
277
+ getCollection(
278
+ id: string,
279
+ options?: RequestOptions,
280
+ ): Promise<ApiRecord | null> {
281
+ return this.http.get<ApiRecord>(
282
+ "/collections/" + encodeURIComponent(id),
283
+ options,
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Create a new collection (admin).
289
+ * @param data Collection definition (name, type, fields, options, rules, etc.).
290
+ * @param options Optional request options.
291
+ */
292
+ createCollection(
293
+ data: Record<string, unknown>,
294
+ options?: RequestOptions,
295
+ ): Promise<ApiRecord | null> {
296
+ return this.http.post<ApiRecord>("/collections", data, options);
297
+ }
298
+
299
+ /**
300
+ * Update an existing collection (admin).
301
+ * @param id Collection ID or name.
302
+ * @param data Updated collection fields.
303
+ * @param options Optional request options.
304
+ */
305
+ updateCollection(
306
+ id: string,
307
+ data: Record<string, unknown>,
308
+ options?: RequestOptions,
309
+ ): Promise<ApiRecord | null> {
310
+ return this.http.patch<ApiRecord>(
311
+ "/collections/" + encodeURIComponent(id),
312
+ data,
313
+ options,
314
+ );
315
+ }
316
+
317
+ /**
318
+ * Delete a collection (admin).
319
+ * @param id Collection ID or name.
320
+ * @param options Optional request options.
321
+ */
322
+ deleteCollection(id: string, options?: RequestOptions): Promise<null> {
323
+ return this.http.delete("/collections/" + encodeURIComponent(id), options);
324
+ }
325
+
326
+ // ── Records (dynamic collection) ──
327
+
328
+ /**
329
+ * List records from a dynamic collection with optional filter/sort/pagination.
330
+ *
331
+ * @param coll Collection name.
332
+ * @param params Query parameters including:
333
+ * - `filter` — PocketBase filter syntax (e.g. `title~'hello' && published=true`)
334
+ * - `sort` — Comma-separated, `-` prefix for DESC (e.g. `-created,title`)
335
+ * - `page` — Page number (default: 1)
336
+ * - `perPage` — Items per page (default: 30, max: 200)
337
+ * - `expand` — Comma-separated relation fields (e.g. `author,category`)
338
+ * @param options Optional request options.
339
+ */
340
+ listRecords(
341
+ coll: string,
342
+ params?: Record<string, string>,
343
+ options?: RequestOptions,
344
+ ): Promise<ListResult<ApiRecord> | null> {
345
+ const qs = params ? "?" + new URLSearchParams(params).toString() : "";
346
+ return this.http.get<ListResult<ApiRecord>>(
347
+ "/" + encodeURIComponent(coll) + qs,
348
+ options,
349
+ );
350
+ }
351
+
352
+ /**
353
+ * Get a single record by ID.
354
+ * @param coll Collection name.
355
+ * @param id Record ID.
356
+ * @param options Optional request options.
357
+ */
358
+ getRecord(
359
+ coll: string,
360
+ id: string,
361
+ options?: RequestOptions,
362
+ ): Promise<ApiRecord | null> {
363
+ return this.http.get<ApiRecord>(
364
+ "/" + encodeURIComponent(coll) + "/" + encodeURIComponent(id),
365
+ options,
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Create a record in a dynamic collection.
371
+ * @param coll Collection name.
372
+ * @param data Record fields.
373
+ * @param options Optional request options.
374
+ */
375
+ createRecord(
376
+ coll: string,
377
+ data: Record<string, unknown>,
378
+ options?: RequestOptions,
379
+ ): Promise<ApiRecord | null> {
380
+ return this.http.post<ApiRecord>(
381
+ "/" + encodeURIComponent(coll),
382
+ data,
383
+ options,
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Update a record in a dynamic collection.
389
+ * @param coll Collection name.
390
+ * @param id Record ID.
391
+ * @param data Updated record fields.
392
+ * @param options Optional request options.
393
+ */
394
+ updateRecord(
395
+ coll: string,
396
+ id: string,
397
+ data: Record<string, unknown>,
398
+ options?: RequestOptions,
399
+ ): Promise<ApiRecord | null> {
400
+ return this.http.patch<ApiRecord>(
401
+ "/" + encodeURIComponent(coll) + "/" + encodeURIComponent(id),
402
+ data,
403
+ options,
404
+ );
405
+ }
406
+
407
+ /**
408
+ * Delete a record from a dynamic collection.
409
+ * @param coll Collection name.
410
+ * @param id Record ID.
411
+ * @param options Optional request options.
412
+ */
413
+ deleteRecord(
414
+ coll: string,
415
+ id: string,
416
+ options?: RequestOptions,
417
+ ): Promise<null> {
418
+ return this.http.delete(
419
+ "/" + encodeURIComponent(coll) + "/" + encodeURIComponent(id),
420
+ options,
421
+ );
422
+ }
423
+ }