diff --git a/README.md b/README.md index 972009ad..55ebc242 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,93 @@ base44 deploy |---------|-------------| | `base44 site deploy` | Deploy built site files to Base44 hosting | +### Types + +| Command | Description | +|---------|-------------| +| `base44 types` | Generate TypeScript types from entity schemas | + +**Options:** +- `-o, --output ` - Output directory (default: `src/base44`) +- `--entities-only` - Only generate entity types, skip client types + +## TypeScript Type Generation + +Generate fully-typed interfaces from your entity schemas for type-safe SDK usage. + +### Usage + +```bash +# Generate types (outputs to src/base44/) +base44 types + +# Custom output directory +base44 types --output ./types +``` + +### Generated Files + +| File | Contents | +|------|----------| +| `entities.ts` | Entity interfaces, CreateInput, UpdateInput, Filter types | +| `client.ts` | Typed SDK client interface | +| `index.ts` | Barrel exports | + +### Setup with @base44/sdk + +1. Generate types: + ```bash + base44 types + ``` + +2. Add to `tsconfig.json`: + ```json + { + "include": ["src", "src/base44/entities.ts"] + } + ``` + +3. Use in your code: + ```typescript + import { createClient } from '@base44/sdk'; + import type { TypedBase44Client } from './base44/client'; + + const base44 = createClient({ appId: 'my-app' }) as TypedBase44Client; + + // Fully typed! + const { items: tasks } = await base44.entities.Task.list(); + await base44.entities.Task.create({ title: 'Buy milk' }); + ``` + +### Example + +Given an entity schema: +```jsonc +// base44/entities/task.jsonc +{ + "name": "Task", + "type": "object", + "properties": { + "title": { "type": "string", "description": "Task title" }, + "completed": { "type": "boolean", "default": false } + }, + "required": ["title"] +} +``` + +Generated types: +```typescript +export interface Task extends BaseEntity { + title: string; + completed?: boolean; +} + +export interface TaskCreateInput { + title: string; + completed?: boolean; +} +``` + ## Configuration ### Project Configuration @@ -115,7 +202,12 @@ my-project/ │ └── my-function/ │ ├── config.jsonc │ └── index.js -├── src/ # Your frontend code +├── src/ +│ ├── base44/ # Generated types (from `base44 types`) +│ │ ├── entities.ts +│ │ ├── client.ts +│ │ └── index.ts +│ └── ... # Your frontend code ├── dist/ # Built site files (for deployment) └── package.json ``` diff --git a/docs/types-command.md b/docs/types-command.md new file mode 100644 index 00000000..0d39e0ee --- /dev/null +++ b/docs/types-command.md @@ -0,0 +1,307 @@ +# `base44 types` Command + +Generate TypeScript declaration files from entity JSON schemas for type-safe SDK usage. + +## Overview + +The `base44 types` command reads your local entity schemas and generates `.d.ts` files that augment the `@base44/sdk` types. This enables full TypeScript support with **zero changes to your code**. + +## Command + +```bash +base44 types # Generate types for Node.js and Deno +base44 types --node-only # Skip Deno types +base44 types -o ./custom # Custom output directory +``` + +## Generated Files + +| File | Target | Module Augmentation | +|------|--------|---------------------| +| `base44/types.d.ts` | Node.js | `declare module '@base44/sdk'` | +| `base44/types.deno.d.ts` | Deno | `declare module 'npm:@base44/sdk'` | + +Both files should be **gitignored** (they are generated artifacts). + +## Setup + +### Node.js + +Add to `tsconfig.json`: +```json +{ + "include": ["src", "base44/types.d.ts"] +} +``` + +Add to `.gitignore`: +```gitignore +base44/types.d.ts +base44/types.deno.d.ts +``` + +### Deno + +Add to `deno.json`: +```json +{ + "compilerOptions": { + "types": ["./base44/types.deno.d.ts"] + } +} +``` + +Or add a reference in your entry file: +```typescript +/// +``` + +## User Experience + +### 1. Define Entities + +```jsonc +// base44/entities/task.jsonc +{ + "name": "Task", + "type": "object", + "properties": { + "title": { "type": "string", "description": "Task title" }, + "completed": { "type": "boolean", "default": false }, + "priority": { "type": "string", "enum": ["low", "medium", "high"] }, + "dueDate": { "type": ["string", "null"] }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "required": ["title"] +} +``` + +### 2. Generate Types + +```bash +base44 types +``` + +Output: +``` +┌ Base 44 +│ +◇ Types generated successfully +│ +● Generated types for 1 entities +● Output directory: /my-project/base44 +● Files: +│ - base44/types.d.ts +│ - base44/types.deno.d.ts +│ +└ Types generated successfully! +``` + +### 3. Use in Code (Unchanged) + +```typescript +import { createClient } from '@base44/sdk'; + +const base44 = createClient({ appId: 'my-app' }); + +// ✅ Fully typed - no casting, no generics, no type imports +const { items } = await base44.entities.Task.list(); + +// ✅ Autocomplete works +items.forEach(task => { + console.log(task.title); // string + console.log(task.completed); // boolean | undefined + console.log(task.priority); // "low" | "medium" | "high" | undefined +}); + +// ✅ Type-safe create +await base44.entities.Task.create({ + title: 'Buy groceries', + priority: 'high' +}); + +// ❌ Compile error: 'titel' does not exist +await base44.entities.Task.create({ titel: 'typo' }); + +// ❌ Compile error: 'urgent' is not assignable to 'low' | 'medium' | 'high' +await base44.entities.Task.create({ title: 'Test', priority: 'urgent' }); +``` + +## Example Generated Types + +### Input: `base44/entities/task.jsonc` + +```jsonc +{ + "name": "Task", + "type": "object", + "properties": { + "title": { "type": "string", "description": "Task title" }, + "completed": { "type": "boolean", "default": false }, + "priority": { "type": "string", "enum": ["low", "medium", "high"] }, + "dueDate": { "type": ["string", "null"] }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "required": ["title"] +} +``` + +### Output: `base44/types.d.ts` (Node.js) + +```typescript +// Auto-generated by Base44 CLI - DO NOT EDIT +// Regenerate with: base44 types +// +// Setup: Add to tsconfig.json: +// { "include": ["src", "base44/types.d.ts"] } + +// ─── Entity Types ──────────────────────────────────────────────── + +export interface Task { + /** + * Task title + */ + title: string; + completed?: boolean; + priority?: "low" | "medium" | "high"; + dueDate?: string | null; + tags?: string[]; +} + +// ─── SDK Type Augmentation ─────────────────────────────────────── + +declare module '@base44/sdk' { + interface EntityTypeRegistry { + Task: Task; + } +} +``` + +### Output: `base44/types.deno.d.ts` (Deno) + +```typescript +// Auto-generated by Base44 CLI - DO NOT EDIT +// Regenerate with: base44 types +// +// Setup: Add to deno.json: +// { "compilerOptions": { "types": ["./base44/types.deno.d.ts"] } } +// +// Or add to your entry file: +// /// + +// ─── Entity Types ──────────────────────────────────────────────── + +export interface Task { + /** + * Task title + */ + title: string; + completed?: boolean; + priority?: "low" | "medium" | "high"; + dueDate?: string | null; + tags?: string[]; +} + +// ─── SDK Type Augmentation ─────────────────────────────────────── + +declare module 'npm:@base44/sdk' { + interface EntityTypeRegistry { + Task: Task; + } +} +``` + +## How It Works + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Entity Schemas │ +│ base44/entities/task.jsonc │ +│ base44/entities/product.jsonc │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. CLI Command │ +│ $ base44 types │ +│ │ +│ Uses json-schema-to-typescript library │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. Generated .d.ts Files │ +│ base44/types.d.ts (Node.js) │ +│ base44/types.deno.d.ts (Deno) │ +│ │ +│ Contains: │ +│ - Entity interfaces │ +│ - declare module '@base44/sdk' { ... } │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. TypeScript Picks Up Types │ +│ Via tsconfig.json include or deno.json types │ +│ │ +│ base44.entities.Task → EntityHandler │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## JSON Schema to TypeScript Mapping + +| JSON Schema | TypeScript | +|-------------|------------| +| `"type": "string"` | `string` | +| `"type": "number"` | `number` | +| `"type": "integer"` | `number` | +| `"type": "boolean"` | `boolean` | +| `"type": "null"` | `null` | +| `"type": "array", "items": {...}` | `T[]` | +| `"type": "object", "properties": {...}` | `{ ... }` | +| `"type": ["string", "null"]` | `string \| null` | +| `"enum": ["a", "b", "c"]` | `"a" \| "b" \| "c"` | + +## SDK Requirements + +For module augmentation to work, the SDK needs these additions: + +```typescript +/** + * Base fields present on all entities. + */ +export interface BaseEntity { + id: string; + created_date: string; + updated_date: string; +} + +/** + * Entity type registry - augmented by generated .d.ts files. + */ +export interface EntityTypeRegistry {} + +/** + * Check if registry has been augmented. + */ +type HasRegistry = keyof EntityTypeRegistry extends never ? false : true; + +/** + * Entities module - typed when registry is augmented, dynamic otherwise. + */ +export type EntitiesModule = HasRegistry extends true + ? { [K in keyof EntityTypeRegistry]: EntityHandler } + : { [entityName: string]: EntityHandler }; +``` + +These SDK changes are **backward compatible** - existing code continues to work, and types "light up" once users generate and include the `.d.ts` files. + +## Workflow + +| Step | Action | Frequency | +|------|--------|-----------| +| 1 | Define entities in `base44/entities/*.jsonc` | When schema changes | +| 2 | Run `base44 types` | After schema changes | +| 3 | Add `.d.ts` to tsconfig/deno.json | Once | +| 4 | Add `.d.ts` to .gitignore | Once | +| 5 | Write code with full type support | Always | diff --git a/package-lock.json b/package-lock.json index a37a8861..260cf39a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "base44", "version": "0.0.15", "license": "ISC", + "dependencies": { + "json-schema-to-typescript": "^15.0.4" + }, "bin": { "base44": "dist/cli/index.js" }, @@ -46,6 +49,23 @@ "node": ">=20.19.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@babel/generator": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", @@ -918,6 +938,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -1676,7 +1702,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -1690,7 +1715,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "dev": true, "license": "MIT" }, "node_modules/@types/lodash.kebabcase": { @@ -2135,7 +2159,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -3671,7 +3694,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4428,7 +4450,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4474,7 +4495,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4827,7 +4847,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4856,6 +4875,29 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4936,6 +4978,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -5027,7 +5075,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5420,7 +5467,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5501,6 +5547,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -6325,7 +6386,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", diff --git a/package.json b/package.json index 73cd9ccf..7ad3a61f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "json5": "^2.2.3", "ky": "^1.14.2", "lodash.kebabcase": "^4.1.1", + "open": "^11.0.0", "p-wait-for": "^6.0.0", "tar": "^7.5.4", "tsdown": "^0.12.4", @@ -60,10 +61,12 @@ "typescript": "^5.7.2", "typescript-eslint": "^8.52.0", "vitest": "^4.0.16", - "zod": "^4.3.5", - "open": "^11.0.0" + "zod": "^4.3.5" }, "engines": { "node": ">=20.19.0" + }, + "dependencies": { + "json-schema-to-typescript": "^15.0.4" } } diff --git a/src/cli/commands/types/generate.ts b/src/cli/commands/types/generate.ts new file mode 100644 index 00000000..e1258e3a --- /dev/null +++ b/src/cli/commands/types/generate.ts @@ -0,0 +1,55 @@ +import { Command } from "commander"; +import { log } from "@clack/prompts"; +import { generateTypes } from "@core/types/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { relative } from "node:path"; + +interface GenerateTypesOptions { + output?: string; + nodeOnly?: boolean; +} + +async function generateTypesAction( + options: GenerateTypesOptions +): Promise { + const result = await runTask( + "Generating TypeScript types", + async () => { + return await generateTypes({ + output: options.output, + nodeOnly: options.nodeOnly, + }); + }, + { + successMessage: "Types generated successfully", + errorMessage: "Failed to generate types", + } + ); + + if (result.entityCount === 0) { + log.warn("No entities found in project"); + return { outroMessage: "No types generated" }; + } + + log.info(`Generated types for ${result.entityCount} entities`); + log.info(`Output directory: ${result.outputDir}`); + + const fileList = result.files + .map((f) => ` - ${relative(process.cwd(), f)}`) + .join("\n"); + log.info(`Files:\n${fileList}`); + + return { outroMessage: "Types generated successfully!" }; +} + +export const typesGenerateCommand = new Command("types") + .description("Generate TypeScript declaration files from entity schemas") + .option("-o, --output ", "Output directory (default: base44)") + .option("--node-only", "Only generate Node.js types, skip Deno types") + .action(async (options: GenerateTypesOptions) => { + await runCommand(() => generateTypesAction(options), { + requireAuth: false, + requireAppConfig: false, + }); + }); diff --git a/src/cli/index.ts b/src/cli/index.ts index d234fb33..982aa454 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import { whoamiCommand } from "./commands/auth/whoami.js"; import { logoutCommand } from "./commands/auth/logout.js"; import { entitiesPushCommand } from "./commands/entities/push.js"; import { functionsDeployCommand } from "./commands/functions/deploy.js"; +import { typesGenerateCommand } from "./commands/types/generate.js"; import { createCommand } from "./commands/project/create.js"; import { dashboardCommand } from "./commands/project/dashboard.js"; import { deployCommand } from "./commands/project/deploy.js"; @@ -43,6 +44,9 @@ program.addCommand(entitiesPushCommand); // Register functions commands program.addCommand(functionsDeployCommand); +// Register types commands +program.addCommand(typesGenerateCommand); + // Register site commands program.addCommand(siteDeployCommand); diff --git a/src/core/index.ts b/src/core/index.ts index 8f600d4c..1e5cf6eb 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,6 +3,7 @@ export * from "./resources/index.js"; export * from "./project/index.js"; export * from "./clients/index.js"; export * from "./site/index.js"; +export * from "./types/index.js"; export * from "./utils/index.js"; export * from "./errors.js"; export * from "./consts.js"; diff --git a/src/core/types/generator.ts b/src/core/types/generator.ts new file mode 100644 index 00000000..c125e732 --- /dev/null +++ b/src/core/types/generator.ts @@ -0,0 +1,105 @@ +import { compile } from "json-schema-to-typescript"; +import type { EntityDefinition } from "./schema.js"; + +const HEADER_NODE = `// Auto-generated by Base44 CLI - DO NOT EDIT +// Regenerate with: base44 types +// +// Setup: Add to tsconfig.json: +// { "include": ["src", "base44/types.d.ts"] } +`; + +const HEADER_DENO = `// Auto-generated by Base44 CLI - DO NOT EDIT +// Regenerate with: base44 types +// +// Setup: Add to deno.json: +// { "compilerOptions": { "types": ["./base44/types.deno.d.ts"] } } +// +// Or add to your entry file: +// /// +`; + +/** Options for json-schema-to-typescript */ +const COMPILE_OPTIONS = { + bannerComment: "", + additionalProperties: false, + strictIndexSignatures: true, + enableConstEnums: true, + declareExternallyReferenced: false, +}; + +/** + * Generate TypeScript interface from JSON Schema + */ +async function generateEntityInterface(entity: EntityDefinition): Promise { + const schema = { + ...entity, + title: entity.name, + $schema: "http://json-schema.org/draft-07/schema#", + }; + + return await compile(schema, entity.name, COMPILE_OPTIONS); +} + +/** + * Generate all entity interfaces + */ +async function generateEntityInterfaces(entities: EntityDefinition[]): Promise { + const interfaces = await Promise.all(entities.map(generateEntityInterface)); + return interfaces.join("\n"); +} + +/** + * Generate the module augmentation block + */ +function generateModuleAugmentation( + entities: EntityDefinition[], + moduleName: string +): string { + if (entities.length === 0) { + return ""; + } + + const registryEntries = entities + .map((e) => ` ${e.name}: ${e.name};`) + .join("\n"); + + return ` +// ─── SDK Type Augmentation ─────────────────────────────────────── + +declare module '${moduleName}' { + interface EntityTypeRegistry { +${registryEntries} + } +} +`; +} + +/** + * Generate types.d.ts for Node.js + * Uses: declare module '@base44/sdk' + */ +export async function generateNodeTypes(entities: EntityDefinition[]): Promise { + const entityInterfaces = await generateEntityInterfaces(entities); + const moduleAugmentation = generateModuleAugmentation(entities, "@base44/sdk"); + + return `${HEADER_NODE} +// ─── Entity Types ──────────────────────────────────────────────── + +${entityInterfaces} +${moduleAugmentation}`; +} + +/** + * Generate types.deno.d.ts for Deno + * Uses: declare module 'npm:@base44/sdk' + */ +export async function generateDenoTypes(entities: EntityDefinition[]): Promise { + const entityInterfaces = await generateEntityInterfaces(entities); + const moduleAugmentation = generateModuleAugmentation(entities, "npm:@base44/sdk"); + + return `${HEADER_DENO} +// ─── Entity Types ──────────────────────────────────────────────── + +${entityInterfaces} +${moduleAugmentation}`; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts new file mode 100644 index 00000000..4484f023 --- /dev/null +++ b/src/core/types/index.ts @@ -0,0 +1,3 @@ +export { generateTypes, type GenerateOptions, type GenerateResult } from "./types.js"; +export { generateNodeTypes, generateDenoTypes } from "./generator.js"; +export { EntityDefinitionSchema, type EntityDefinition, type EntityField } from "./schema.js"; diff --git a/src/core/types/schema.ts b/src/core/types/schema.ts new file mode 100644 index 00000000..c7519b37 --- /dev/null +++ b/src/core/types/schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +/** JSON Schema primitive types */ +export type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "array" | "object" | "null"; + +/** + * Entity field type definition (JSON Schema compatible) + * Supports both single types and union types (e.g., ["string", "null"]) + */ +export interface EntityField { + type: JsonSchemaType | JsonSchemaType[]; + description?: string; + default?: unknown; + /** Enum values - can be strings, numbers, or mixed */ + enum?: (string | number | boolean | null)[]; + /** For array types - schema of array items */ + items?: EntityField; + /** For object types - nested property schemas */ + properties?: Record; + /** For object types - list of required property names */ + required?: string[]; + /** JSON Schema format hint (date, date-time, email, uri, etc.) */ + format?: string; + [key: string]: unknown; +} + +/** + * Entity definition type + */ +export interface EntityDefinition { + name: string; + type?: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +/** + * Schema for a full entity definition + * Uses loose validation to allow JSON Schema flexibility + */ +export const EntityDefinitionSchema = z.object({ + name: z.string().min(1, "Entity name cannot be empty"), + type: z.literal("object").optional(), + properties: z.record(z.string(), z.unknown()).optional(), + required: z.array(z.string()).optional(), +}).passthrough(); diff --git a/src/core/types/types.ts b/src/core/types/types.ts new file mode 100644 index 00000000..a7914c74 --- /dev/null +++ b/src/core/types/types.ts @@ -0,0 +1,98 @@ +import { join, dirname } from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { readProjectConfig, findProjectRoot } from "../project/config.js"; +import { EntityDefinitionSchema, type EntityDefinition } from "./schema.js"; +import { generateNodeTypes, generateDenoTypes } from "./generator.js"; + +export interface GenerateOptions { + /** Output directory relative to project root (default: "base44") */ + output?: string; + /** Skip generating Deno types */ + nodeOnly?: boolean; +} + +export interface GenerateResult { + /** Number of entities processed */ + entityCount: number; + /** Files that were generated */ + files: string[]; + /** Output directory path */ + outputDir: string; +} + +/** + * Parse entities into EntityDefinition format for type generation + */ +function parseEntities(entities: Record[]): EntityDefinition[] { + return entities + .map((entity) => { + const result = EntityDefinitionSchema.safeParse(entity); + if (result.success) { + return result.data; + } + // Skip invalid entities but log warning with error details + const entityName = (entity as { name?: string }).name ?? "unknown"; + console.warn( + `Skipping invalid entity "${entityName}": ${result.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join(", ")}` + ); + return null; + }) + .filter((e): e is EntityDefinition => e !== null); +} + +/** + * Generate TypeScript declaration files from local entity schemas. + * + * Generates: + * - types.d.ts - For Node.js (declare module '@base44/sdk') + * - types.deno.d.ts - For Deno (declare module 'npm:@base44/sdk') + */ +export async function generateTypes( + options: GenerateOptions = {} +): Promise { + const { output = "base44", nodeOnly = false } = options; + + // Find project root + const projectRoot = await findProjectRoot(); + if (!projectRoot) { + throw new Error( + "Project root not found. Please run this command from within a Base44 project." + ); + } + + // Read project config and entities + const { entities } = await readProjectConfig(projectRoot.root); + + // Parse entities into our schema format + const parsedEntities = parseEntities(entities); + + // Determine output directory (base44/ by default) + const outputDir = join(projectRoot.root, output); + + // Ensure output directory exists + await mkdir(outputDir, { recursive: true }); + + const files: string[] = []; + + // Generate Node.js types.d.ts + const nodeTypesContent = await generateNodeTypes(parsedEntities); + const nodeTypesPath = join(outputDir, "types.d.ts"); + await writeFile(nodeTypesPath, nodeTypesContent, "utf-8"); + files.push(nodeTypesPath); + + // Generate Deno types.deno.d.ts (unless node-only) + if (!nodeOnly) { + const denoTypesContent = await generateDenoTypes(parsedEntities); + const denoTypesPath = join(outputDir, "types.deno.d.ts"); + await writeFile(denoTypesPath, denoTypesContent, "utf-8"); + files.push(denoTypesPath); + } + + return { + entityCount: parsedEntities.length, + files, + outputDir, + }; +}