baasix 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.
@@ -0,0 +1,485 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ cancel,
6
+ confirm,
7
+ intro,
8
+ isCancel,
9
+ log,
10
+ outro,
11
+ select,
12
+ spinner,
13
+ text,
14
+ } from "@clack/prompts";
15
+ import chalk from "chalk";
16
+ import { Command } from "commander";
17
+ import { format as prettierFormat } from "prettier";
18
+ import { getConfig } from "../utils/get-config.js";
19
+ import { fetchSchemas, type SchemaInfo, type FieldDefinition } from "../utils/api-client.js";
20
+
21
+ type GenerateTarget = "types" | "sdk-types" | "schema-json";
22
+
23
+ interface GenerateOptions {
24
+ cwd: string;
25
+ output?: string;
26
+ target?: GenerateTarget;
27
+ url?: string;
28
+ yes?: boolean;
29
+ }
30
+
31
+ async function generateAction(opts: GenerateOptions) {
32
+ const cwd = path.resolve(opts.cwd);
33
+
34
+ intro(chalk.bgBlue.black(" Baasix Type Generator "));
35
+
36
+ // Load config
37
+ const config = await getConfig(cwd);
38
+ if (!config && !opts.url) {
39
+ log.error("No Baasix configuration found. Create a .env file with BAASIX_URL or use --url flag.");
40
+ process.exit(1);
41
+ }
42
+
43
+ const baasixUrl = opts.url || config?.url || "http://localhost:8056";
44
+
45
+ // Select generation target
46
+ let target = opts.target;
47
+ if (!target) {
48
+ const result = await select({
49
+ message: "What do you want to generate?",
50
+ options: [
51
+ {
52
+ value: "types",
53
+ label: "TypeScript Types",
54
+ hint: "Generate types for all collections",
55
+ },
56
+ {
57
+ value: "sdk-types",
58
+ label: "SDK Collection Types",
59
+ hint: "Generate typed SDK helpers for collections",
60
+ },
61
+ {
62
+ value: "schema-json",
63
+ label: "Schema JSON",
64
+ hint: "Export all schemas as JSON",
65
+ },
66
+ ],
67
+ });
68
+
69
+ if (isCancel(result)) {
70
+ cancel("Operation cancelled");
71
+ process.exit(0);
72
+ }
73
+ target = result as GenerateTarget;
74
+ }
75
+
76
+ // Get output path
77
+ let outputPath = opts.output;
78
+ if (!outputPath) {
79
+ const defaultPath = target === "schema-json" ? "schemas.json" : "baasix.d.ts";
80
+ const result = await text({
81
+ message: "Output file path:",
82
+ placeholder: defaultPath,
83
+ defaultValue: defaultPath,
84
+ });
85
+
86
+ if (isCancel(result)) {
87
+ cancel("Operation cancelled");
88
+ process.exit(0);
89
+ }
90
+ outputPath = result as string;
91
+ }
92
+
93
+ const s = spinner();
94
+ s.start("Fetching schemas from Baasix...");
95
+
96
+ try {
97
+ // Fetch schemas from API
98
+ const schemas = await fetchSchemas({
99
+ url: baasixUrl,
100
+ email: config?.email,
101
+ password: config?.password,
102
+ token: config?.token,
103
+ });
104
+
105
+ if (!schemas || schemas.length === 0) {
106
+ s.stop("No schemas found");
107
+ log.warn("No schemas found in your Baasix instance.");
108
+ process.exit(0);
109
+ }
110
+
111
+ s.message(`Found ${schemas.length} schemas`);
112
+
113
+ let output: string;
114
+
115
+ if (target === "types") {
116
+ output = generateTypeScriptTypes(schemas);
117
+ } else if (target === "sdk-types") {
118
+ output = generateSDKTypes(schemas);
119
+ } else {
120
+ output = JSON.stringify(schemas, null, 2);
121
+ }
122
+
123
+ // Format with prettier if TypeScript
124
+ if (target !== "schema-json") {
125
+ try {
126
+ output = await prettierFormat(output, {
127
+ parser: "typescript",
128
+ printWidth: 100,
129
+ tabWidth: 2,
130
+ singleQuote: true,
131
+ });
132
+ } catch {
133
+ // Ignore prettier errors
134
+ }
135
+ }
136
+
137
+ // Check if file exists
138
+ const fullOutputPath = path.resolve(cwd, outputPath);
139
+ if (existsSync(fullOutputPath) && !opts.yes) {
140
+ s.stop("File already exists");
141
+ const overwrite = await confirm({
142
+ message: `File ${outputPath} already exists. Overwrite?`,
143
+ initialValue: true,
144
+ });
145
+
146
+ if (isCancel(overwrite) || !overwrite) {
147
+ cancel("Operation cancelled");
148
+ process.exit(0);
149
+ }
150
+ s.start("Writing file...");
151
+ }
152
+
153
+ // Ensure directory exists
154
+ const outputDir = path.dirname(fullOutputPath);
155
+ if (!existsSync(outputDir)) {
156
+ await fs.mkdir(outputDir, { recursive: true });
157
+ }
158
+
159
+ await fs.writeFile(fullOutputPath, output);
160
+
161
+ s.stop("Types generated successfully");
162
+
163
+ outro(chalk.green(`✨ Generated ${outputPath}`));
164
+
165
+ // Print usage info
166
+ if (target === "types" || target === "sdk-types") {
167
+ console.log();
168
+ console.log(chalk.bold("Usage:"));
169
+ console.log(` ${chalk.dim("// Import types in your TypeScript files")}`);
170
+ console.log(` ${chalk.cyan(`import type { Products, Users } from "./${outputPath.replace(/\.d\.ts$/, "")}";`)}`);
171
+ console.log();
172
+ }
173
+
174
+ } catch (error) {
175
+ s.stop("Failed to generate types");
176
+ if (error instanceof Error) {
177
+ log.error(error.message);
178
+ } else {
179
+ log.error("Unknown error occurred");
180
+ }
181
+ process.exit(1);
182
+ }
183
+ }
184
+
185
+ function fieldTypeToTS(field: FieldDefinition, allSchemas?: SchemaInfo[]): { type: string; jsdoc?: string } {
186
+ // Handle relation fields
187
+ if (field.relType && field.target) {
188
+ const targetType = toPascalCase(field.target);
189
+
190
+ // Check if it's a system collection (baasix_*)
191
+ const isSystemCollection = field.target.startsWith("baasix_");
192
+
193
+ // For HasMany and BelongsToMany, return array type
194
+ if (field.relType === "HasMany" || field.relType === "BelongsToMany") {
195
+ return { type: `${targetType}[] | null` };
196
+ }
197
+
198
+ // For BelongsTo and HasOne, return single type
199
+ return { type: `${targetType} | null` };
200
+ }
201
+
202
+ const type = field.type?.toUpperCase(); // Normalize to uppercase for comparison
203
+
204
+ // Handle nullable
205
+ const nullable = field.allowNull !== false;
206
+ const nullSuffix = nullable ? " | null" : "";
207
+
208
+ // Build JSDoc comment for validations
209
+ const jsdocParts: string[] = [];
210
+
211
+ if (field.validate) {
212
+ if (field.validate.min !== undefined) jsdocParts.push(`@min ${field.validate.min}`);
213
+ if (field.validate.max !== undefined) jsdocParts.push(`@max ${field.validate.max}`);
214
+ if (field.validate.len) jsdocParts.push(`@length ${field.validate.len[0]}-${field.validate.len[1]}`);
215
+ if (field.validate.isEmail) jsdocParts.push(`@format email`);
216
+ if (field.validate.isUrl) jsdocParts.push(`@format url`);
217
+ if (field.validate.isIP) jsdocParts.push(`@format ip`);
218
+ if (field.validate.isUUID) jsdocParts.push(`@format uuid`);
219
+ if (field.validate.regex) jsdocParts.push(`@pattern ${field.validate.regex}`);
220
+ }
221
+
222
+ // Add length info for strings
223
+ if (field.values && typeof field.values === 'object' && !Array.isArray(field.values)) {
224
+ const vals = field.values as Record<string, unknown>;
225
+ if (vals.length) jsdocParts.push(`@maxLength ${vals.length}`);
226
+ if (vals.precision && vals.scale) jsdocParts.push(`@precision ${vals.precision},${vals.scale}`);
227
+ }
228
+
229
+ const jsdoc = jsdocParts.length > 0 ? jsdocParts.join(' ') : undefined;
230
+
231
+ switch (type) {
232
+ case "STRING":
233
+ case "TEXT":
234
+ case "UUID":
235
+ case "SUID":
236
+ return { type: `string${nullSuffix}`, jsdoc };
237
+
238
+ case "INTEGER":
239
+ case "BIGINT":
240
+ case "FLOAT":
241
+ case "REAL":
242
+ case "DOUBLE":
243
+ case "DECIMAL":
244
+ return { type: `number${nullSuffix}`, jsdoc };
245
+
246
+ case "BOOLEAN":
247
+ return { type: `boolean${nullSuffix}`, jsdoc };
248
+
249
+ case "DATE":
250
+ case "DATETIME":
251
+ case "TIME":
252
+ return { type: `string${nullSuffix}`, jsdoc }; // ISO date strings
253
+
254
+ case "JSON":
255
+ case "JSONB":
256
+ return { type: `Record<string, unknown>${nullSuffix}`, jsdoc };
257
+
258
+ case "ARRAY": {
259
+ const vals = field.values as Record<string, unknown> | undefined;
260
+ const arrayType = vals?.type as string || "unknown";
261
+ const innerType = arrayType.toUpperCase() === "STRING" ? "string" :
262
+ arrayType.toUpperCase() === "INTEGER" ? "number" :
263
+ arrayType.toUpperCase() === "BOOLEAN" ? "boolean" : "unknown";
264
+ return { type: `${innerType}[]${nullSuffix}`, jsdoc };
265
+ }
266
+
267
+ case "ENUM": {
268
+ // Enum values can be directly in field.values as array or in field.values.values
269
+ let enumValues: string[] | undefined;
270
+
271
+ if (Array.isArray(field.values)) {
272
+ enumValues = field.values as string[];
273
+ } else if (field.values && typeof field.values === 'object') {
274
+ const vals = field.values as Record<string, unknown>;
275
+ if (Array.isArray(vals.values)) {
276
+ enumValues = vals.values as string[];
277
+ }
278
+ }
279
+
280
+ if (enumValues && enumValues.length > 0) {
281
+ const enumType = enumValues.map((v: string) => `"${v}"`).join(" | ");
282
+ return { type: `(${enumType})${nullSuffix}`, jsdoc };
283
+ }
284
+ return { type: `string${nullSuffix}`, jsdoc };
285
+ }
286
+
287
+ case "GEOMETRY":
288
+ case "POINT":
289
+ case "LINESTRING":
290
+ case "POLYGON":
291
+ return { type: `GeoJSON.Geometry${nullSuffix}`, jsdoc };
292
+
293
+ default:
294
+ return { type: `unknown${nullSuffix}`, jsdoc };
295
+ }
296
+ }
297
+
298
+ function toPascalCase(str: string): string {
299
+ return str
300
+ .split(/[-_]/)
301
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
302
+ .join("");
303
+ }
304
+
305
+ function generateTypeScriptTypes(schemas: SchemaInfo[]): string {
306
+ const lines: string[] = [
307
+ "/**",
308
+ " * Auto-generated TypeScript types for Baasix collections",
309
+ ` * Generated at: ${new Date().toISOString()}`,
310
+ " * ",
311
+ " * Do not edit this file manually. Re-run 'baasix generate types' to update.",
312
+ " */",
313
+ "",
314
+ "// GeoJSON types for PostGIS fields",
315
+ "declare namespace GeoJSON {",
316
+ " interface Point { type: 'Point'; coordinates: [number, number]; }",
317
+ " interface LineString { type: 'LineString'; coordinates: [number, number][]; }",
318
+ " interface Polygon { type: 'Polygon'; coordinates: [number, number][][]; }",
319
+ " type Geometry = Point | LineString | Polygon;",
320
+ "}",
321
+ "",
322
+ ];
323
+
324
+ // Collect all referenced system collections
325
+ const referencedSystemCollections = new Set<string>();
326
+ for (const schema of schemas) {
327
+ for (const field of Object.values(schema.schema.fields)) {
328
+ const fieldDef = field as FieldDefinition;
329
+ if (fieldDef.relType && fieldDef.target && fieldDef.target.startsWith("baasix_")) {
330
+ referencedSystemCollections.add(fieldDef.target);
331
+ }
332
+ }
333
+ }
334
+
335
+ // Generate types for referenced system collections first
336
+ const systemSchemas = schemas.filter(
337
+ (s) => referencedSystemCollections.has(s.collectionName)
338
+ );
339
+
340
+ for (const schema of systemSchemas) {
341
+ const typeName = toPascalCase(schema.collectionName);
342
+ const fields = schema.schema.fields;
343
+
344
+ lines.push(`/**`);
345
+ lines.push(` * ${schema.schema.name || schema.collectionName} (system collection)`);
346
+ lines.push(` */`);
347
+ lines.push(`export interface ${typeName} {`);
348
+
349
+ for (const [fieldName, field] of Object.entries(fields)) {
350
+ const fieldDef = field as FieldDefinition;
351
+ // Skip relation fields for system collections to avoid circular refs
352
+ if (fieldDef.relType) continue;
353
+
354
+ const { type: tsType, jsdoc } = fieldTypeToTS(fieldDef, schemas);
355
+ const optional = fieldDef.allowNull !== false && !fieldDef.primaryKey ? "?" : "";
356
+ if (jsdoc) {
357
+ lines.push(` /** ${jsdoc} */`);
358
+ }
359
+ lines.push(` ${fieldName}${optional}: ${tsType};`);
360
+ }
361
+
362
+ lines.push(`}`);
363
+ lines.push("");
364
+ }
365
+
366
+ // Filter out system collections for user schemas
367
+ const userSchemas = schemas.filter(
368
+ (s) => !s.collectionName.startsWith("baasix_")
369
+ );
370
+
371
+ for (const schema of userSchemas) {
372
+ const typeName = toPascalCase(schema.collectionName);
373
+ const fields = schema.schema.fields;
374
+
375
+ lines.push(`/**`);
376
+ lines.push(` * ${schema.schema.name || schema.collectionName} collection`);
377
+ lines.push(` */`);
378
+ lines.push(`export interface ${typeName} {`);
379
+
380
+ for (const [fieldName, field] of Object.entries(fields)) {
381
+ const fieldDef = field as FieldDefinition;
382
+ const { type: tsType, jsdoc } = fieldTypeToTS(fieldDef, schemas);
383
+ const optional = fieldDef.allowNull !== false && !fieldDef.primaryKey ? "?" : "";
384
+ if (jsdoc) {
385
+ lines.push(` /** ${jsdoc} */`);
386
+ }
387
+ lines.push(` ${fieldName}${optional}: ${tsType};`);
388
+ }
389
+
390
+ // Add timestamp fields if enabled
391
+ if (schema.schema.timestamps) {
392
+ lines.push(` createdAt?: string;`);
393
+ lines.push(` updatedAt?: string;`);
394
+ }
395
+
396
+ // Add soft delete field if paranoid
397
+ if (schema.schema.paranoid) {
398
+ lines.push(` deletedAt?: string | null;`);
399
+ }
400
+
401
+ lines.push(`}`);
402
+ lines.push("");
403
+ }
404
+
405
+ // Generate a union type of all collection names
406
+ lines.push("/**");
407
+ lines.push(" * All collection names");
408
+ lines.push(" */");
409
+ lines.push("export type CollectionName =");
410
+ for (const schema of userSchemas) {
411
+ lines.push(` | "${schema.collectionName}"`);
412
+ }
413
+ lines.push(";");
414
+ lines.push("");
415
+
416
+ // Generate a type map
417
+ lines.push("/**");
418
+ lines.push(" * Map collection names to their types");
419
+ lines.push(" */");
420
+ lines.push("export interface CollectionTypeMap {");
421
+ for (const schema of userSchemas) {
422
+ const typeName = toPascalCase(schema.collectionName);
423
+ lines.push(` ${schema.collectionName}: ${typeName};`);
424
+ }
425
+ lines.push("}");
426
+ lines.push("");
427
+
428
+ return lines.join("\n");
429
+ }
430
+
431
+ function generateSDKTypes(schemas: SchemaInfo[]): string {
432
+ const lines: string[] = [
433
+ "/**",
434
+ " * Auto-generated typed SDK helpers for Baasix collections",
435
+ ` * Generated at: ${new Date().toISOString()}`,
436
+ " * ",
437
+ " * Do not edit this file manually. Re-run 'baasix generate sdk-types' to update.",
438
+ " */",
439
+ "",
440
+ 'import { createBaasix } from "@tspvivek/baasix-sdk";',
441
+ 'import type { QueryParams, Filter, PaginatedResponse } from "@tspvivek/baasix-sdk";',
442
+ "",
443
+ ];
444
+
445
+ // Generate types first
446
+ lines.push(generateTypeScriptTypes(schemas));
447
+
448
+ // Generate typed items helper
449
+ lines.push("/**");
450
+ lines.push(" * Create a typed Baasix client with collection-specific methods");
451
+ lines.push(" */");
452
+ lines.push("export function createTypedBaasix(config: Parameters<typeof createBaasix>[0]) {");
453
+ lines.push(" const client = createBaasix(config);");
454
+ lines.push("");
455
+ lines.push(" return {");
456
+ lines.push(" ...client,");
457
+ lines.push(" /**");
458
+ lines.push(" * Type-safe items access");
459
+ lines.push(" */");
460
+ lines.push(" collections: {");
461
+
462
+ const userSchemas = schemas.filter((s) => !s.collectionName.startsWith("baasix_"));
463
+
464
+ for (const schema of userSchemas) {
465
+ const typeName = toPascalCase(schema.collectionName);
466
+ lines.push(` ${schema.collectionName}: client.items<${typeName}>("${schema.collectionName}"),`);
467
+ }
468
+
469
+ lines.push(" },");
470
+ lines.push(" };");
471
+ lines.push("}");
472
+ lines.push("");
473
+
474
+ return lines.join("\n");
475
+ }
476
+
477
+ export const generate = new Command("generate")
478
+ .alias("gen")
479
+ .description("Generate TypeScript types from Baasix schemas")
480
+ .option("-c, --cwd <path>", "Working directory", process.cwd())
481
+ .option("-o, --output <path>", "Output file path")
482
+ .option("-t, --target <target>", "Generation target (types, sdk-types, schema-json)")
483
+ .option("--url <url>", "Baasix server URL")
484
+ .option("-y, --yes", "Skip confirmation prompts")
485
+ .action(generateAction);