lazypock 0.1.0 → 0.1.2

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/codegen.ts ADDED
@@ -0,0 +1,149 @@
1
+ // ── Codegen ─────────────────────────────────────────────
2
+ // Generates a `lazypock.types.ts` module from the live API schema.
3
+ //
4
+ // The generated file exports:
5
+ // - One interface per collection (e.g. `PostsRecord`)
6
+ // - A `LazypockCollections` map: { posts: PostsRecord; users: UsersRecord; ... }
7
+ // - A `createClient()` factory pre-bound to those types, so
8
+ // `client.collection("posts").create({ title: "x" })` is fully type-checked.
9
+ //
10
+ // The CLI in `src/cli.ts` wires this to `GET /collections`.
11
+
12
+ import type { CollectionSchema, SchemaField } from "./schema";
13
+ import { fieldTypeScriptType } from "./typegen";
14
+
15
+ /** Format a raw collection name into a valid TS identifier (PascalCase). */
16
+ export function collectionTypeName(name: string): string {
17
+ return name
18
+ .split(/[^a-zA-Z0-9]+/)
19
+ .filter(Boolean)
20
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
21
+ .join("")
22
+ .replace(/^[0-9]/, "_$&");
23
+ }
24
+
25
+ /** Interface member name — sanitize hyphens/spaces but keep readability. */
26
+ export function fieldKey(name: string): string {
27
+ return name.replace(/[^a-zA-Z0-9_]/g, "_");
28
+ }
29
+
30
+
31
+
32
+ /**
33
+ * Generate the full TypeScript source for the typed SDK module.
34
+ *
35
+ * @param collections Collections fetched from the API.
36
+ * @param options Generation options.
37
+ */
38
+ export function generateTypes(
39
+ collections: CollectionSchema[],
40
+ options: {
41
+ /** Import specifier for the lazypock package (default `lazypock`). */
42
+ packageName?: string;
43
+ /** Emit base record fields (id, created, updated, …). Default true. */
44
+ includeBaseFields?: boolean;
45
+ /** Skip system collections (names starting with `_` or `users`). Default false. */
46
+ skipSystem?: boolean;
47
+ } = {},
48
+ ): string {
49
+ const {
50
+ packageName = "lazypock",
51
+ includeBaseFields = true,
52
+ skipSystem = false,
53
+ } = options;
54
+
55
+ const filtered = skipSystem
56
+ ? collections.filter(
57
+ (c) => !c.system && !c.name.startsWith("_") && c.name !== "users",
58
+ )
59
+ : collections;
60
+
61
+ const sections: string[] = [];
62
+ sections.push(`// ── Auto-generated by lazypock-ts ──────────────────────────
63
+ // Do not edit by hand. Regenerate with: npx lazypock-gen
64
+ // Schema snapshot: ${new Date().toISOString()}`);
65
+
66
+ if (includeBaseFields) {
67
+ sections.push(`export interface BaseRecord {
68
+ id: string;
69
+ collectionId: string;
70
+ collectionName: string;
71
+ created: string;
72
+ updated: string;
73
+ }`);
74
+ }
75
+
76
+ // One interface per collection
77
+ for (const coll of filtered) {
78
+ const typeName = collectionTypeName(coll.name);
79
+ const fields = coll.fields ?? [];
80
+ const lines = fields.map((f) => memberLine(f)).filter((l) => l !== "");
81
+ const body = lines.join("\n");
82
+ sections.push(
83
+ `export interface ${typeName}Record${renderInterface({
84
+ extends: includeBaseFields ? "BaseRecord" : undefined,
85
+ body,
86
+ })}`,
87
+ );
88
+ }
89
+
90
+ // Auth collection type
91
+ sections.push(`export interface AuthRecord extends BaseRecord {
92
+ email: string;
93
+ verified: boolean;
94
+ }`);
95
+
96
+ // Collections map
97
+ const mapEntries = filtered
98
+ .map(
99
+ (c) =>
100
+ ` "${c.name}": ${collectionTypeName(c.name)}Record${
101
+ c.type === "auth" ? " & AuthRecord" : ""
102
+ };`,
103
+ )
104
+ .join("\n");
105
+ sections.push(`export interface LazypockCollections {
106
+ ${mapEntries}
107
+ }`);
108
+
109
+ // createClient factory
110
+ sections.push(`import { LazypockClient, type LazypockClientOptions, type CollectionService } from "${packageName}";
111
+
112
+ /**
113
+ * Create a Lazypock client typed against this schema snapshot.
114
+ * Collection access is fully type-checked:
115
+ * client.collection("posts").create({ title: "x" }) // title must exist
116
+ */
117
+ export function createClient(options: LazypockClientOptions): TypedClient {
118
+ return new TypedClient(options);
119
+ }
120
+
121
+ export class TypedClient extends LazypockClient {
122
+ override collection<K extends keyof LazypockCollections>(name: K): CollectionService<LazypockCollections[K]>;
123
+ override collection(name: string): CollectionService<unknown> {
124
+ return super.collection(name) as unknown as CollectionService<unknown>;
125
+ }
126
+ }`);
127
+
128
+ return sections.join("\n\n") + "\n";
129
+ }
130
+
131
+ /**
132
+ * Render the interface body including the extends clause.
133
+ * Empty body → ` extends BaseRecord {}` (valid TS).
134
+ */
135
+ function renderInterface(opts: { extends?: string; body: string }): string {
136
+ const ext = opts.extends ? ` extends ${opts.extends}` : "";
137
+ if (!opts.body) return `${ext} {}`;
138
+ return `${ext} {\n${opts.body}\n}`;
139
+ }
140
+
141
+ /** Render a single interface member line for a field. */
142
+ function memberLine(f: SchemaField): string {
143
+ const key = fieldKey(f.name);
144
+ const req = f.required || f.type === "password" ? "" : "?";
145
+ const type = fieldTypeScriptType(f);
146
+ // `never` members (passwords) are omitted from read models.
147
+ if (type === "never") return "";
148
+ return ` ${JSON.stringify(key)}${req}: ${type};`;
149
+ }
package/src/collection.ts CHANGED
@@ -3,22 +3,75 @@
3
3
 
4
4
  import type { HttpClient } from "./http";
5
5
  import type { AuthStore, AuthModel } from "./auth";
6
- import type { ApiRecord, ListResult, RequestOptions } from "./types";
6
+ import type {
7
+ ApiRecord,
8
+ ListResult,
9
+ RequestOptions,
10
+ CreateData,
11
+ UpdateData,
12
+ } from "./types";
13
+ import type { RealtimeService } from "./realtime";
14
+
15
+ /**
16
+ * A realtime record-change event delivered to subscription callbacks.
17
+ * Mirrors PocketBase's RealtimeService result shape (`action` + `record`).
18
+ */
19
+ export interface RealtimeMessage {
20
+ action: "create" | "update" | "delete";
21
+ record: Record<string, unknown>;
22
+ topic?: string;
23
+ }
24
+
25
+ /** Subscription callback for a collection's realtime events. */
26
+ export type RealtimeCallback = (e: RealtimeMessage) => void;
27
+
28
+ /** Raw event passed by the low-level realtime service. */
29
+ interface RealtimeEventLike {
30
+ event: string;
31
+ topic: string;
32
+ payload?: Record<string, unknown>;
33
+ }
34
+
35
+ /** Map a raw event/action to a normalised create/update/delete action. */
36
+ function normalizeAction(
37
+ event: string,
38
+ rawAction?: unknown,
39
+ ): RealtimeMessage["action"] {
40
+ if (typeof rawAction === "string") {
41
+ const a = rawAction.toLowerCase();
42
+ if (a === "create" || a === "update" || a === "delete") {
43
+ return a;
44
+ }
45
+ }
46
+ if (event === "record_change") return "update";
47
+ if (event === "create" || event === "record_create") return "create";
48
+ if (event === "delete" || event === "record_delete") return "delete";
49
+ return "update";
50
+ }
7
51
 
8
52
  /**
9
53
  * Typed CRUD service for a single dynamic collection.
10
54
  * Get an instance via {@link LazypockClient.collection}.
55
+ *
56
+ * @typeParam T — The record shape for this collection. Defaults to {@link ApiRecord}.
11
57
  */
12
- export class CollectionService {
58
+ export class CollectionService<T = ApiRecord> {
13
59
  private http: HttpClient;
14
60
  private collectionName: string;
15
61
  private authStore?: AuthStore;
62
+ private realtime?: RealtimeService;
16
63
 
17
64
  /** @internal */
18
- constructor(http: HttpClient, collectionName: string, authStore?: AuthStore) {
65
+ constructor(
66
+ http: HttpClient,
67
+ collectionName: string,
68
+ authStore?: AuthStore,
69
+ realtime?: RealtimeService,
70
+ ) {
19
71
  this.http = http;
20
72
  this.collectionName = collectionName;
21
73
  this.authStore = authStore;
74
+ this.realtime = realtime;
22
75
  }
23
76
 
24
77
  private encodeId(id: string): string {
@@ -30,7 +83,7 @@ export class CollectionService {
30
83
  * @param params Query parameters including `filter`, `sort`, `page`, `perPage`, `expand`.
31
84
  * @param options Optional request options.
32
85
  */
33
- list<T = ApiRecord>(
86
+ list(
34
87
  params?: Record<string, string>,
35
88
  options?: RequestOptions,
36
89
  ): Promise<ListResult<T> | null> {
@@ -46,10 +99,7 @@ export class CollectionService {
46
99
  * @param id Record ID.
47
100
  * @param options Optional request options.
48
101
  */
49
- getOne<T = ApiRecord>(
50
- id: string,
51
- options?: RequestOptions,
52
- ): Promise<T | null> {
102
+ getOne(id: string, options?: RequestOptions): Promise<T | null> {
53
103
  return this.http.get<T>(
54
104
  "/" + this.encodeId(this.collectionName) + "/" + this.encodeId(id),
55
105
  options,
@@ -58,11 +108,12 @@ export class CollectionService {
58
108
 
59
109
  /**
60
110
  * Create a new record.
61
- * @param data Record fields.
111
+ * @param data Record fields. When `T` is a concrete shape (e.g. a generated
112
+ * record type), excess/unknown fields are rejected at compile time.
62
113
  * @param options Optional request options.
63
114
  */
64
- create<T = ApiRecord>(
65
- data: Record<string, unknown>,
115
+ create(
116
+ data: T extends ApiRecord ? Record<string, unknown> : CreateData<T>,
66
117
  options?: RequestOptions,
67
118
  ): Promise<T | null> {
68
119
  return this.http.post<T>(
@@ -75,12 +126,13 @@ export class CollectionService {
75
126
  /**
76
127
  * Update a record by ID.
77
128
  * @param id Record ID.
78
- * @param data Updated record fields.
129
+ * @param data Updated record fields. When `T` is a concrete shape, `data`
130
+ * must be a partial of `T` — unknown fields are rejected.
79
131
  * @param options Optional request options.
80
132
  */
81
- update<T = ApiRecord>(
133
+ update(
82
134
  id: string,
83
- data: Record<string, unknown>,
135
+ data: T extends ApiRecord ? Record<string, unknown> : UpdateData<T>,
84
136
  options?: RequestOptions,
85
137
  ): Promise<T | null> {
86
138
  return this.http.patch<T>(
@@ -111,11 +163,12 @@ export class CollectionService {
111
163
  options?: RequestOptions,
112
164
  ): Promise<{ field: string; targetCollection: string }[] | null> {
113
165
  const data = await this.http.get<{
114
- fields?: { name: string; type: string; options?: Record<string, string> }[];
115
- }>(
116
- "/collections/" + this.encodeId(this.collectionName),
117
- options,
118
- );
166
+ fields?: {
167
+ name: string;
168
+ type: string;
169
+ options?: Record<string, string>;
170
+ }[];
171
+ }>("/collections/" + this.encodeId(this.collectionName), options);
119
172
  if (!data?.fields) return null;
120
173
  return data.fields
121
174
  .filter((f) => f.type === "relation" && f.options?.collection)
@@ -125,6 +178,71 @@ export class CollectionService {
125
178
  }));
126
179
  }
127
180
 
181
+ /**
182
+ * Cast this service to a specific record shape.
183
+ * Use when you have a hand-written or generated interface for the
184
+ * collection and want compile-time checking of create/update/list.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * interface Post {
189
+ * id: string;
190
+ * title: string;
191
+ * published: boolean;
192
+ * }
193
+ * const posts = client.collection("posts").typed<Post>();
194
+ * await posts.create({ title: "Hi", published: true }); // ✓
195
+ * await posts.create({ nope: 1 }); // ✗ compile error
196
+ * ```
197
+ */
198
+ typed<TRecord = ApiRecord>(): CollectionService<TRecord> {
199
+ return this as unknown as CollectionService<TRecord>;
200
+ }
201
+
202
+ // ── Realtime Subscriptions (PocketBase-style) ──
203
+
204
+ /**
205
+ * Subscribe to realtime changes for this collection.
206
+ * The event's `action` is one of `"create" | "update" | "delete"`.
207
+ *
208
+ * Access is governed by the collection's `listRule` (PocketBase semantics):
209
+ * public collections allow anonymous subscriptions; other collections
210
+ * require a matching logged-in user or superuser.
211
+ *
212
+ * @param callback Received on every record change.
213
+ * @param recordId Optional — subscribe to a single record instead of `*`.
214
+ * @returns A function that unsubscribes this callback.
215
+ */
216
+ subscribe(callback: RealtimeCallback, recordId?: string): () => void {
217
+ if (!this.realtime) {
218
+ console.warn("[lazypock] No realtime service configured.");
219
+ return () => {};
220
+ }
221
+ const topic =
222
+ "collection:" + this.collectionName + (recordId ? ":" + recordId : "");
223
+ const handler = (raw: RealtimeEventLike) => {
224
+ const record = (raw.payload?.["record"] ?? {}) as Record<string, unknown>;
225
+ callback({
226
+ action: normalizeAction(raw.event, raw.payload?.["action"]),
227
+ record,
228
+ topic: raw.topic,
229
+ });
230
+ };
231
+ this.realtime.ensureConnected();
232
+ this.realtime.subscribe(topic, handler as never);
233
+ return () => this.realtime?.unsubscribe(topic, handler as never);
234
+ }
235
+
236
+ /**
237
+ * Unsubscribe all callbacks from this collection (or a specific record).
238
+ * @param recordId Optional record id; omitting it unsubs everything.
239
+ */
240
+ unsubscribe(recordId?: string): void {
241
+ const topic =
242
+ "collection:" + this.collectionName + (recordId ? ":" + recordId : "");
243
+ this.realtime?.unsubscribe(topic);
244
+ }
245
+
128
246
  // ── Auth Collection Methods ──
129
247
 
130
248
  /**
@@ -135,7 +253,9 @@ export class CollectionService {
135
253
  identity: string,
136
254
  password: string,
137
255
  options?: RequestOptions,
138
- ): Promise<({ token: string; record: ApiRecord } & Record<string, unknown>) | null> {
256
+ ): Promise<
257
+ ({ token: string; record: ApiRecord } & Record<string, unknown>) | null
258
+ > {
139
259
  const data = await this.http.post<
140
260
  { token: string; record: ApiRecord } & Record<string, unknown>
141
261
  >(
@@ -156,7 +276,9 @@ export class CollectionService {
156
276
  */
157
277
  async authRefresh(
158
278
  options?: RequestOptions,
159
- ): Promise<({ token: string; record: ApiRecord } & Record<string, unknown>) | null> {
279
+ ): Promise<
280
+ ({ token: string; record: ApiRecord } & Record<string, unknown>) | null
281
+ > {
160
282
  const data = await this.http.post<
161
283
  { token: string; record: ApiRecord } & Record<string, unknown>
162
284
  >(
@@ -174,6 +296,8 @@ export class CollectionService {
174
296
  /**
175
297
  * Get available auth methods for this collection.
176
298
  */
299
+ // ── end Realtime ──
300
+
177
301
  async authMethods(
178
302
  options?: RequestOptions,
179
303
  ): Promise<Record<string, unknown> | null> {