diff --git a/betterbase/README.md b/betterbase/README.md index c1bd43f..3a86c06 100644 --- a/betterbase/README.md +++ b/betterbase/README.md @@ -27,7 +27,7 @@ From the monorepo root: - `bun install` - `bun run dev` - `bun run build` -- `bun run typecheck` (runs `turbo run typecheck --filter '*'`) +- `bun run typecheck` (runs `turbo run typecheck --filter "*"`) > Note: `templates/base` is not in the root workspace graph (`apps/*`, `packages/*`), so run template checks separately (e.g. `cd templates/base && bun run typecheck`). @@ -41,3 +41,14 @@ From `templates/base`: - `bun run build` - `bun run start` - `bun run typecheck` + + +## CLI Highlights + +- `bb auth setup [project-root]` — scaffold BetterAuth tables, middleware, and routes. + - Example: `bun run --cwd packages/cli dev auth setup ../../templates/base` +- `bb generate crud [project-root]` — generate CRUD routes for a schema table. + - Example: `bun run --cwd packages/cli dev generate crud users ../../templates/base` + +Realtime support is built into the base template via `/ws` and `src/lib/realtime.ts`. Generated CRUD routes broadcast insert/update/delete events to subscribers. +For command details and flags, run `bb --help`, `bb auth --help`, and `bb generate --help`. diff --git a/betterbase/bun.lock b/betterbase/bun.lock new file mode 100644 index 0000000..7d7c59a --- /dev/null +++ b/betterbase/bun.lock @@ -0,0 +1,175 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "betterbase", + "devDependencies": { + "turbo": "^2.0.0", + "typescript": "^5.6.0", + }, + }, + "apps/cli": { + "name": "@betterbase/cli-legacy", + "version": "0.0.0", + "bin": { + "bb-legacy": "./dist/index.js", + }, + "dependencies": { + "@betterbase/cli": "workspace:*", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, + "packages/cli": { + "name": "@betterbase/cli", + "version": "0.1.0", + "bin": { + "bb": "./dist/index.js", + }, + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0", + "inquirer": "^10.2.2", + "typescript": "^5.8.0", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "^1.3.9", + }, + }, + "packages/client": { + "name": "@betterbase/client", + "version": "0.1.0", + "devDependencies": { + "@types/bun": "^1.3.9", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@betterbase/cli": ["@betterbase/cli@workspace:packages/cli"], + + "@betterbase/cli-legacy": ["@betterbase/cli-legacy@workspace:apps/cli"], + + "@betterbase/client": ["@betterbase/client@workspace:packages/client"], + + "@inquirer/checkbox": ["@inquirer/checkbox@2.5.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" } }, "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA=="], + + "@inquirer/confirm": ["@inquirer/confirm@3.2.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3" } }, "sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw=="], + + "@inquirer/core": ["@inquirer/core@9.2.1", "", { "dependencies": { "@inquirer/figures": "^1.0.6", "@inquirer/type": "^2.0.0", "@types/mute-stream": "^0.0.4", "@types/node": "^22.5.5", "@types/wrap-ansi": "^3.0.0", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^1.0.0", "signal-exit": "^4.1.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" } }, "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg=="], + + "@inquirer/editor": ["@inquirer/editor@2.2.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "external-editor": "^3.1.0" } }, "sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw=="], + + "@inquirer/expand": ["@inquirer/expand@2.3.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" } }, "sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "@inquirer/input": ["@inquirer/input@2.3.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3" } }, "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw=="], + + "@inquirer/number": ["@inquirer/number@1.1.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3" } }, "sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA=="], + + "@inquirer/password": ["@inquirer/password@2.2.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2" } }, "sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg=="], + + "@inquirer/prompts": ["@inquirer/prompts@5.5.0", "", { "dependencies": { "@inquirer/checkbox": "^2.5.0", "@inquirer/confirm": "^3.2.0", "@inquirer/editor": "^2.2.0", "@inquirer/expand": "^2.3.0", "@inquirer/input": "^2.3.0", "@inquirer/number": "^1.1.0", "@inquirer/password": "^2.2.0", "@inquirer/rawlist": "^2.3.0", "@inquirer/search": "^1.1.0", "@inquirer/select": "^2.5.0" } }, "sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@2.3.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" } }, "sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ=="], + + "@inquirer/search": ["@inquirer/search@1.1.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" } }, "sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ=="], + + "@inquirer/select": ["@inquirer/select@2.5.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" } }, "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA=="], + + "@inquirer/type": ["@inquirer/type@1.5.5", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/mute-stream": ["@types/mute-stream@0.0.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="], + + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "inquirer": ["inquirer@10.2.2", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/prompts": "^5.5.0", "@inquirer/type": "^1.5.3", "@types/mute-stream": "^0.0.4", "ansi-escapes": "^4.3.2", "mute-stream": "^1.0.0", "run-async": "^3.0.0", "rxjs": "^7.8.1" } }, "sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "mute-stream": ["mute-stream@1.0.0", "", {}, "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA=="], + + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + + "run-async": ["run-async@3.0.0", "", {}, "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "turbo": ["turbo@2.8.10", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.10", "turbo-darwin-arm64": "2.8.10", "turbo-linux-64": "2.8.10", "turbo-linux-arm64": "2.8.10", "turbo-windows-64": "2.8.10", "turbo-windows-arm64": "2.8.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="], + + "turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="], + + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="], + + "turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="], + + "type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@inquirer/core/@inquirer/type": ["@inquirer/type@2.0.0", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag=="], + + "@inquirer/core/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "@inquirer/core/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/betterbase/package.json b/betterbase/package.json index 34bc804..3765b1a 100644 --- a/betterbase/package.json +++ b/betterbase/package.json @@ -10,7 +10,7 @@ "build": "turbo run build", "dev": "turbo run dev --parallel", "lint": "turbo run lint", - "typecheck": "turbo run typecheck --filter '*'" + "typecheck": "turbo run typecheck --filter \"*\"" }, "devDependencies": { "turbo": "^2.0.0", diff --git a/betterbase/packages/cli/package.json b/betterbase/packages/cli/package.json index 54f6fb8..f297fe2 100644 --- a/betterbase/packages/cli/package.json +++ b/betterbase/packages/cli/package.json @@ -16,11 +16,11 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "inquirer": "^10.2.2", - "zod": "^3.23.8", - "typescript": "^5.3.0" + "zod": "^3.23.8" }, "devDependencies": { - "@types/bun": "^1.3.9" + "@types/bun": "^1.3.9", + "typescript": "^5.8.0" }, "exports": { ".": "./src/index.ts" diff --git a/betterbase/packages/cli/src/commands/auth.ts b/betterbase/packages/cli/src/commands/auth.ts index 86c4952..9f89137 100644 --- a/betterbase/packages/cli/src/commands/auth.ts +++ b/betterbase/packages/cli/src/commands/auth.ts @@ -44,7 +44,20 @@ const loginSchema = z.object({ }); authRoute.post('/signup', async (c) => { - const body = signupSchema.parse(await c.req.json()); + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch (err) { + const details = err instanceof Error ? err.message : String(err); + return c.json({ error: 'Invalid JSON', details }, 400); + } + + const result = signupSchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: 'Invalid signup payload', details: result.error.format() }, 400); + } + + const body = result.data; const passwordHash = await Bun.password.hash(body.password); const created = await db @@ -56,17 +69,35 @@ authRoute.post('/signup', async (c) => { }) .returning(); + const createdUser = created[0]; + if (!createdUser) { + return c.json({ error: 'Failed to create user record' }, 500); + } + return c.json({ user: { - id: created[0].id, - email: created[0].email, - name: created[0].name, + id: createdUser.id, + email: createdUser.email, + name: createdUser.name, }, }, 201); }); authRoute.post('/login', async (c) => { - const body = loginSchema.parse(await c.req.json()); + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch (err) { + const details = err instanceof Error ? err.message : String(err); + return c.json({ error: 'Invalid JSON', details }, 400); + } + + const result = loginSchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: 'Invalid login payload', details: result.error.format() }, 400); + } + + const body = result.data; const user = await db.select().from(users).where(eq(users.email, body.email)).limit(1); if (user.length === 0 || !user[0].passwordHash) { @@ -142,7 +173,16 @@ async function validateSession(token: string): Promise 0 ? user[0] : null; } @@ -196,14 +236,94 @@ function ensurePasswordHashColumn(schemaPath: string): void { return; } - const usersBlock = current.match(/export\s+const\s+users\s*=\s*sqliteTable\([^]+?\}\);/m); - if (!usersBlock) { + const usersExportIdx = current.search(/export\s+const\s+users\s*=\s*sqliteTable\s*\(/); + if (usersExportIdx === -1) { logger.warn('Could not find sqlite users table block; skipping passwordHash injection.'); return; } - const replacement = usersBlock[0].replace(/\n\}\);$/, "\n passwordHash: text('password_hash').notNull(),\n});"); - writeFileSync(schemaPath, current.replace(usersBlock[0], replacement)); + const callStart = current.indexOf('sqliteTable(', usersExportIdx); + if (callStart === -1) { + logger.warn('Could not locate sqliteTable call for users; skipping passwordHash injection.'); + return; + } + + let i = callStart; + let parenDepth = 0; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let escaped = false; + + while (i < current.length) { + const ch = current[i]; + + if (escaped) { + escaped = false; + i += 1; + continue; + } + + if ((inSingle || inDouble || inBacktick) && ch === '\\') { + escaped = true; + i += 1; + continue; + } + + if (!inDouble && !inBacktick && ch === "'") { + inSingle = !inSingle; + i += 1; + continue; + } + + if (!inSingle && !inBacktick && ch === '"') { + inDouble = !inDouble; + i += 1; + continue; + } + + if (!inSingle && !inDouble && ch === '`') { + inBacktick = !inBacktick; + i += 1; + continue; + } + + if (inSingle || inDouble || inBacktick) { + i += 1; + continue; + } + + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + break; + } + } + + i += 1; + } + + if (i >= current.length || parenDepth !== 0) { + logger.warn('Could not safely parse users sqliteTable block; skipping passwordHash injection.'); + return; + } + + const statementEnd = current.indexOf(';', i); + if (statementEnd === -1) { + logger.warn('Could not locate end of users sqliteTable statement; skipping passwordHash injection.'); + return; + } + + const usersBlock = current.slice(usersExportIdx, statementEnd + 1); + const replacement = usersBlock.replace(/\n\}\);\s*$/, "\n passwordHash: text('password_hash').notNull(),\n});"); + if (replacement === usersBlock) { + logger.warn('Could not inject passwordHash into users table; block layout was unexpected.'); + return; + } + + writeFileSync(schemaPath, `${current.slice(0, usersExportIdx)}${replacement}${current.slice(statementEnd + 1)}`); } function ensureAuthInConfig(projectRoot: string): void { @@ -253,17 +373,31 @@ function ensureRoutesIndexHook(projectRoot: string): void { const routesIndexPath = path.join(projectRoot, 'src/routes/index.ts'); if (!existsSync(routesIndexPath)) return; - let current = readFileSync(routesIndexPath, 'utf-8'); + const current = readFileSync(routesIndexPath, 'utf-8'); + const importAnchor = "import { usersRoute } from './users';"; + const routeAnchor = "app.route('/api/users', usersRoute);"; + + let next = current; - if (!current.includes("import { authRoute } from './auth';")) { - current = current.replace("import { usersRoute } from './users';", "import { usersRoute } from './users';\nimport { authRoute } from './auth';"); + if (!next.includes("import { authRoute } from './auth';")) { + if (next.includes(importAnchor)) { + next = next.replace(importAnchor, `${importAnchor}\nimport { authRoute } from './auth';`); + } else { + logger.warn(`Could not find import anchor in ${routesIndexPath}; skipping auth route import injection.`); + } } - if (!current.includes("app.route('/auth', authRoute);")) { - current = current.replace("app.route('/api/users', usersRoute);", "app.route('/api/users', usersRoute);\n app.route('/auth', authRoute);"); + if (!next.includes("app.route('/auth', authRoute);")) { + if (next.includes(routeAnchor)) { + next = next.replace(routeAnchor, `${routeAnchor}\n app.route('/auth', authRoute);`); + } else { + logger.warn(`Could not find route anchor in ${routesIndexPath}; skipping auth route registration injection.`); + } } - writeFileSync(routesIndexPath, current); + if (next !== current) { + writeFileSync(routesIndexPath, next); + } } export async function runAuthSetupCommand(projectRoot: string = process.cwd()): Promise { diff --git a/betterbase/packages/cli/src/commands/generate.ts b/betterbase/packages/cli/src/commands/generate.ts index 1735e30..874642f 100644 --- a/betterbase/packages/cli/src/commands/generate.ts +++ b/betterbase/packages/cli/src/commands/generate.ts @@ -6,26 +6,11 @@ import * as logger from '../utils/logger'; function toSingular(name: string): string { const lower = name.toLowerCase(); const invariants = new Set(['status', 'news', 'series']); - if (invariants.has(lower)) { - return name; - } - - if (/men$/i.test(name)) { - return name.replace(/men$/i, 'man'); - } - - if (/ies$/i.test(name)) { - return name.replace(/ies$/i, 'y'); - } - - if (/(ses|xes|zes|ches|shes)$/i.test(name)) { - return name.replace(/es$/i, ''); - } - - if (name.endsWith('s') && !name.endsWith('ss')) { - return name.slice(0, -1); - } - + if (invariants.has(lower)) return name; + if (/men$/i.test(name)) return name.replace(/men$/i, 'man'); + if (/ies$/i.test(name)) return name.replace(/ies$/i, 'y'); + if (/(ses|xes|zes|ches|shes)$/i.test(name)) return name.replace(/es$/i, ''); + if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1); return `${name}Item`; } @@ -48,12 +33,28 @@ function buildSchemaShape(table: TableInfo, mode: 'create' | 'update'): string { .join(',\n'); } +function buildFilterableColumns(table: TableInfo): string { + return Object.entries(table.columns) + .filter(([, column]) => !column.primaryKey) + .map(([column]) => ` '${column}',`) + .join('\n'); +} + +function buildFilterCoercers(table: TableInfo): string { + return Object.entries(table.columns) + .filter(([, column]) => !column.primaryKey) + .map(([column, info]) => ` ${column}: ${schemaTypeToZod(info.type)},`) + .join('\n'); +} + function generateRouteFile(tableName: string, table: TableInfo): string { const singular = toSingular(tableName); const createShape = buildSchemaShape(table, 'create'); const updateShape = buildSchemaShape(table, 'update'); + const filterableColumns = buildFilterableColumns(table); + const filterCoercers = buildFilterCoercers(table); - return `import { and, asc, desc, eq } from 'drizzle-orm'; + return `import { and, asc, desc, eq, inArray } from 'drizzle-orm'; import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; @@ -71,23 +72,79 @@ const updateSchema = z.object({ ${updateShape} }); -${tableName}Route.get('/', async (c) => { - const limit = Number(c.req.query('limit') ?? 50); - const offset = Number(c.req.query('offset') ?? 0); - const safeLimit = Number.isFinite(limit) && limit >= 0 ? Math.min(limit, 100) : 50; - const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; +const DEFAULT_OFFSET = 0; + +const paginationSchema = z.object({ + limit: z.coerce.number().int().nonnegative().default(DEFAULT_LIMIT), + offset: z.coerce.number().int().nonnegative().default(DEFAULT_OFFSET), +}); + +const FILTERABLE_COLUMNS = new Set([ +${filterableColumns} +]); + +const FILTER_COERCE = { +${filterCoercers} +} as const; +${tableName}Route.get('/', async (c) => { const queryParams = c.req.query(); - const sort = queryParams.sort; + const paginationResult = paginationSchema.safeParse({ + limit: queryParams.limit, + offset: queryParams.offset, + }); + + if (!paginationResult.success) { + return c.json({ error: 'Invalid pagination params', details: paginationResult.error.format() }, 400); + } - const filters = Object.entries(queryParams).filter(([key, value]) => key !== 'limit' && key !== 'offset' && key !== 'sort' && value !== undefined); + const { limit, offset } = paginationResult.data; + const fetchLimit = Math.min(limit, MAX_LIMIT); + const sort = queryParams.sort; + const filters = Object.entries(queryParams).filter( + ([key, value]) => key !== 'limit' && key !== 'offset' && key !== 'sort' && value !== undefined, + ); let query = db.select().from(${tableName}).$dynamic(); if (filters.length > 0) { const conditions = filters - .filter(([key]) => key in ${tableName}) - .map(([key, value]) => eq(${tableName}[key as keyof typeof ${tableName}] as never, value as never)); + .flatMap(([rawKey, value]) => { + if (rawKey.endsWith('_in')) { + const key = rawKey.slice(0, -3); + if (!FILTERABLE_COLUMNS.has(key)) return []; + + const schema = FILTER_COERCE[key as keyof typeof FILTER_COERCE]; + if (!schema) return []; + + try { + const parsedInValues = JSON.parse(String(value)); + if (!Array.isArray(parsedInValues)) return []; + + const coercedValues = parsedInValues + .map((item) => schema.safeParse(item)) + .filter((result) => result.success) + .map((result) => result.data); + + if (coercedValues.length === 0) return []; + + return [inArray(${tableName}[key as keyof typeof ${tableName}] as never, coercedValues as never[])]; + } catch { + return []; + } + } + + if (!FILTERABLE_COLUMNS.has(rawKey)) return []; + const schema = FILTER_COERCE[rawKey as keyof typeof FILTER_COERCE]; + if (!schema) return []; + + const parsed = schema.safeParse(value); + if (!parsed.success) return []; + + return [eq(${tableName}[rawKey as keyof typeof ${tableName}] as never, parsed.data as never)]; + }); if (conditions.length > 0) { query = query.where(and(...conditions)); @@ -102,8 +159,13 @@ ${tableName}Route.get('/', async (c) => { } } - const items = await query.limit(safeLimit).offset(safeOffset); - return c.json({ ${tableName}: items, count: items.length, pagination: { limit: safeLimit, offset: safeOffset } }); + const items = await query.limit(fetchLimit + 1).offset(offset); + const hasMore = items.length > fetchLimit; + + return c.json({ + ${tableName}: items.slice(0, fetchLimit), + pagination: { limit: fetchLimit, offset, hasMore }, + }); }); ${tableName}Route.get('/:id', async (c) => { @@ -164,7 +226,9 @@ function updateMainRouter(projectRoot: string, tableName: string): void { if (!router.includes(importLine)) { const firstRouteImport = /import\s+\{\s*healthRoute\s*\}\s+from\s+'\.\/health';/; - router = firstRouteImport.test(router) ? router.replace(firstRouteImport, (m) => `${m}\n${importLine}`) : `${importLine}\n${router}`; + router = firstRouteImport.test(router) + ? router.replace(firstRouteImport, (m) => `${m}\n${importLine}`) + : `${importLine}\n${router}`; } if (!router.includes(routeLine)) { @@ -185,7 +249,7 @@ function ensureRealtimeUtility(projectRoot: string): void { const realtimePath = path.join(projectRoot, 'src/lib/realtime.ts'); if (existsSync(realtimePath)) return; - const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../templates/base/src/lib/realtime.ts'); + const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../../templates/base/src/lib/realtime.ts'); if (!existsSync(canonicalRealtimePath)) { throw new Error(`Canonical realtime template not found at ${canonicalRealtimePath}`); } @@ -195,17 +259,49 @@ function ensureRealtimeUtility(projectRoot: string): void { } async function ensureZodValidatorInstalled(projectRoot: string): Promise { + let current = path.resolve(projectRoot); + + while (true) { + const modulePath = path.join(current, 'node_modules', '@hono', 'zod-validator'); + if (existsSync(modulePath)) return; + + const packageJsonPath = path.join(current, 'package.json'); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }; + + if ( + packageJson.dependencies?.['@hono/zod-validator'] + || packageJson.devDependencies?.['@hono/zod-validator'] + || packageJson.peerDependencies?.['@hono/zod-validator'] + ) { + return; + } + } catch { + // Fall through to install branch. + } + } + + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + logger.info('Installing @hono/zod-validator...'); - const process = Bun.spawn(['bun', 'add', '@hono/zod-validator'], { + const child = Bun.spawn(['bun', 'add', '@hono/zod-validator'], { cwd: projectRoot, stdout: 'pipe', stderr: 'pipe', }); const [exitCode, stdout, stderr] = await Promise.all([ - process.exited, - new Response(process.stdout).text(), - new Response(process.stderr).text(), + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), ]); if (exitCode !== 0) { diff --git a/betterbase/packages/cli/src/commands/init.ts b/betterbase/packages/cli/src/commands/init.ts index 0e7c6ac..97bd374 100644 --- a/betterbase/packages/cli/src/commands/init.ts +++ b/betterbase/packages/cli/src/commands/init.ts @@ -85,6 +85,8 @@ function buildPackageJson(projectName: string, databaseMode: DatabaseMode, useAu type: 'module', scripts: { dev: 'bun run src/index.ts', + build: 'bun build src/index.ts --outfile dist/index.js --target bun', + start: 'bun run dist/index.js', 'db:generate': 'drizzle-kit generate', 'db:push': 'bun run src/db/migrate.ts', }, @@ -107,7 +109,7 @@ function buildDrizzleConfig(databaseMode: DatabaseMode): string { }; const databaseUrl: Record = { - local: "process.env.DATABASE_URL || 'file:local.db'", + local: "process.env.DB_PATH ? `file:${process.env.DB_PATH}` : 'file:local.db'", neon: "process.env.DATABASE_URL || 'postgres://localhost'", turso: "process.env.DATABASE_URL || 'libsql://localhost'", }; @@ -121,6 +123,7 @@ export default defineConfig({ out: './drizzle', dialect: '${dialect[databaseMode]}', dbCredentials: { + // Keep local fallback in sync with src/lib/env.ts DEFAULT_DB_PATH url: ${databaseUrl[databaseMode]},${tursoAuthTokenLine} }, }); @@ -255,9 +258,10 @@ try { return `import { Database } from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; +import { env } from '../lib/env'; try { - const sqlite = new Database(process.env.DB_PATH ?? 'local.db', { create: true }); + const sqlite = new Database(env.DB_PATH, { create: true }); const db = drizzle(sqlite); migrate(db, { migrationsFolder: './drizzle' }); @@ -300,9 +304,10 @@ export const db = drizzle(client, { schema }); return `import { Database } from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { env } from '../lib/env'; import * as schema from './schema'; -const client = new Database(process.env.DB_PATH ?? 'local.db', { create: true }); +const client = new Database(env.DB_PATH, { create: true }); export const db = drizzle(client, { schema }); `; @@ -531,8 +536,11 @@ const DEFAULT_LIMIT = 25; const MAX_LIMIT = 100; const DEFAULT_OFFSET = 0; +// Intentionally permissive: undefined, non-integer, or negative inputs fall back to caller defaults +// (DEFAULT_LIMIT / DEFAULT_OFFSET with MAX_LIMIT clamping applied by caller). If strict validation +// is needed, callers should parse with Zod and return 400 instead of using this helper. function parseNonNegativeInt(value: string | undefined, fallback: number): number { - if (!value) { + if (!value || value.trim() === '') { return fallback; } @@ -588,11 +596,14 @@ usersRoute.post('/', async (c) => { const body = await c.req.json(); const parsed = parseBody(createUserSchema, body); - // TODO: persist parsed user via db.insert(users) or a dedicated UsersService. + const created = await db.insert(users).values(parsed).returning(); + if (created.length === 0) { + throw new HTTPException(500, { message: 'Failed to persist user' }); + } + return c.json({ - message: 'User payload validated (not persisted)', - user: parsed, - }); + user: created[0], + }, 201); } catch (error) { if (error instanceof HTTPException) { throw error; diff --git a/betterbase/packages/cli/src/commands/migrate.ts b/betterbase/packages/cli/src/commands/migrate.ts index af22934..f8e98be 100644 --- a/betterbase/packages/cli/src/commands/migrate.ts +++ b/betterbase/packages/cli/src/commands/migrate.ts @@ -263,10 +263,102 @@ async function restoreBackup(backup: MigrationBackup | null): Promise { } function splitStatements(sql: string): string[] { - return sql - .split(/;\s*/g) - .map((statement) => statement.trim()) - .filter((statement) => statement.length > 0); + const statements: string[] = []; + let current = ''; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < sql.length; i += 1) { + const ch = sql[i]; + const next = sql[i + 1]; + + if (inLineComment) { + current += ch; + if (ch === ' +') { + inLineComment = false; + } + continue; + } + + if (inBlockComment) { + current += ch; + if (ch === '*' && next === '/') { + current += next; + i += 1; + inBlockComment = false; + } + continue; + } + + if (!inSingle && !inDouble && !inBacktick && ch === '-' && next === '-') { + current += ch + next; + i += 1; + inLineComment = true; + continue; + } + + if (!inSingle && !inDouble && !inBacktick && ch === '/' && next === '*') { + current += ch + next; + i += 1; + inBlockComment = true; + continue; + } + + if (!inDouble && !inBacktick && ch === "'") { + current += ch; + if (inSingle && next === "'") { + current += next; + i += 1; + continue; + } + inSingle = !inSingle; + continue; + } + + if (!inSingle && !inBacktick && ch === '"') { + current += ch; + if (inDouble && next === '"') { + current += next; + i += 1; + continue; + } + inDouble = !inDouble; + continue; + } + + if (!inSingle && !inDouble && ch === '`') { + current += ch; + if (inBacktick && next === '`') { + current += next; + i += 1; + continue; + } + inBacktick = !inBacktick; + continue; + } + + if (ch === ';' && !inSingle && !inDouble && !inBacktick && !inLineComment && !inBlockComment) { + const statement = current.trim(); + if (statement.length > 0) { + statements.push(statement); + } + current = ''; + continue; + } + + current += ch; + } + + const tail = current.trim(); + if (tail.length > 0) { + statements.push(tail); + } + + return statements; } async function collectChangesFromGenerate(): Promise { @@ -324,6 +416,7 @@ export async function runMigrateCommand(rawOptions: MigrateCommandOptions): Prom } logger.info('Applying migrations with drizzle-kit push...'); + logger.info('drizzle/ files are for preview; running push will apply changes.'); const push = await runDrizzleKit(['push']); if (!push.success) { @@ -340,5 +433,6 @@ export async function runMigrateCommand(rawOptions: MigrateCommandOptions): Prom throw new Error(`Migration push failed.\n${push.stderr || push.stdout}`); } + logger.info('drizzle-kit push completed; changes applied.'); logger.success('Migration complete!'); } diff --git a/betterbase/packages/cli/src/index.ts b/betterbase/packages/cli/src/index.ts index 25e8e04..b0d3b9c 100644 --- a/betterbase/packages/cli/src/index.ts +++ b/betterbase/packages/cli/src/index.ts @@ -33,7 +33,39 @@ export function createProgram(): Command { .description('Watch schema/routes and regenerate .betterbase-context.json') .argument('[project-root]', 'project root directory', process.cwd()) .action(async (projectRoot: string) => { - await runDevCommand(projectRoot); + const cleanup = await runDevCommand(projectRoot); + + let cleanedUp = false; + const onExit = (): void => { + if (!cleanedUp) { + cleanedUp = true; + try { + cleanup(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Dev cleanup failed: ${message}`); + } + } + + process.off('SIGINT', onSigInt); + process.off('SIGTERM', onSigTerm); + process.off('exit', onProcessExit); + }; + const onSigInt = (): void => { + onExit(); + process.exit(0); + }; + const onSigTerm = (): void => { + onExit(); + process.exit(0); + }; + const onProcessExit = (): void => { + onExit(); + }; + + process.on('SIGINT', onSigInt); + process.on('SIGTERM', onSigTerm); + process.on('exit', onProcessExit); }); @@ -59,22 +91,22 @@ export function createProgram(): Command { await runGenerateCrudCommand(projectRoot, tableName); }); - program - .command('migrate') - .description('Generate and apply migrations for local development') + const migrate = program.command('migrate').description('Generate and apply migrations for local development'); + + migrate .action(async () => { await runMigrateCommand({}); }); - program - .command('migrate:preview') + migrate + .command('preview') .description('Preview migration diff without applying changes') .action(async () => { await runMigrateCommand({ preview: true }); }); - program - .command('migrate:production') + migrate + .command('production') .description('Apply migrations to production (requires confirmation)') .action(async () => { await runMigrateCommand({ production: true }); diff --git a/betterbase/packages/cli/src/utils/context-generator.ts b/betterbase/packages/cli/src/utils/context-generator.ts index 794c8ee..3bdf904 100644 --- a/betterbase/packages/cli/src/utils/context-generator.ts +++ b/betterbase/packages/cli/src/utils/context-generator.ts @@ -44,7 +44,7 @@ export class ContextGenerator { const outputPath = path.join(projectRoot, '.betterbase-context.json'); writeFileSync(outputPath, `${JSON.stringify(context, null, 2)}\n`); - console.log(`✅ Generated ${outputPath}`); + logger.success(`Generated ${outputPath}`); return context; } diff --git a/betterbase/packages/cli/src/utils/route-scanner.ts b/betterbase/packages/cli/src/utils/route-scanner.ts index d5cd2c9..57b5c21 100644 --- a/betterbase/packages/cli/src/utils/route-scanner.ts +++ b/betterbase/packages/cli/src/utils/route-scanner.ts @@ -22,6 +22,8 @@ function isAuthLikeName(value: string): boolean { return /\bauth\b/i.test(value) || /^auth/i.test(value) || /^(authMiddleware|requireAuth)$/i.test(value); } +const httpMethods = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']); + function collectTsFiles(dir: string): string[] { const files: string[] = []; @@ -108,7 +110,6 @@ export class RouteScanner { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.text.toLowerCase(); - const httpMethods = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']); if (httpMethods.has(method)) { const [pathArg, ...handlerArgs] = node.arguments; diff --git a/betterbase/packages/cli/src/utils/scanner.ts b/betterbase/packages/cli/src/utils/scanner.ts index 0dee86d..7165c56 100644 --- a/betterbase/packages/cli/src/utils/scanner.ts +++ b/betterbase/packages/cli/src/utils/scanner.ts @@ -1,22 +1,31 @@ import { readFileSync } from 'node:fs'; import * as ts from 'typescript'; +import { z } from 'zod'; +import * as logger from './logger'; -export interface ColumnInfo { - name: string; - type: string; - nullable: boolean; - unique: boolean; - primaryKey: boolean; - defaultValue?: string; - references?: string; -} +export const ColumnTypeSchema = z.enum(['text', 'integer', 'number', 'boolean', 'datetime', 'json', 'blob', 'unknown']); -export interface TableInfo { - name: string; - columns: Record; - relations: string[]; - indexes: string[]; -} +export const ColumnInfoSchema = z.object({ + name: z.string(), + type: ColumnTypeSchema, + nullable: z.boolean(), + unique: z.boolean(), + primaryKey: z.boolean(), + defaultValue: z.string().optional(), + references: z.string().optional(), +}); + +export const TableInfoSchema = z.object({ + name: z.string(), + columns: z.record(z.string(), ColumnInfoSchema), + relations: z.array(z.string()), + indexes: z.array(z.string()), +}); + +export const TablesRecordSchema = z.record(z.string(), TableInfoSchema); + +export type ColumnInfo = z.infer; +export type TableInfo = z.infer; function unwrapExpression(expression: ts.Expression): ts.Expression { let current = expression; @@ -27,8 +36,7 @@ function unwrapExpression(expression: ts.Expression): ts.Expression { ts.isTypeAssertionExpression(current) || ts.isSatisfiesExpression(current) ) { - current = (current as ts.ParenthesizedExpression | ts.AsExpression | ts.TypeAssertion | ts.SatisfiesExpression) - .expression; + current = (current as ts.ParenthesizedExpression | ts.AsExpression | ts.TypeAssertion | ts.SatisfiesExpression).expression; } return current; @@ -87,7 +95,9 @@ export class SchemaScanner { const functionName = getCallName(initializer); if (functionName === 'sqliteTable' || functionName === 'pgTable' || functionName === 'mysqlTable') { - tables[declaration.name.text] = this.parseTable(initializer); + const tableObj = this.parseTable(initializer); + const tableKey = tableObj.name || declaration.name.text; + tables[tableKey] = tableObj; } } } @@ -96,7 +106,8 @@ export class SchemaScanner { }; visit(this.sourceFile); - return tables; + + return TablesRecordSchema.parse(tables); } private parseTable(callExpression: ts.CallExpression): TableInfo { @@ -151,19 +162,35 @@ export class SchemaScanner { continue; } - const value = unwrapExpression(property.initializer); - if (!ts.isCallExpression(value)) { - continue; - } - - const callName = getCallName(value); - if (callName === 'index' || callName === 'uniqueIndex') { - const key = ts.isIdentifier(property.name) - ? property.name.text - : ts.isStringLiteral(property.name) + let value = unwrapExpression(property.initializer); + const MAX_ITER = 50; + let iter = 0; + + while (ts.isCallExpression(value)) { + iter += 1; + if (iter > MAX_ITER) { + logger.warn( + `SchemaScanner parseIndexes reached MAX_ITER=${MAX_ITER} while scanning index chain: ${value.getText(this.sourceFile)}`, + ); + break; + } + const callName = getCallName(value); + if (callName === 'index' || callName === 'uniqueIndex') { + const key = ts.isIdentifier(property.name) ? property.name.text - : property.name.getText(this.sourceFile); - indexes.push(key); + : ts.isStringLiteral(property.name) + ? property.name.text + : property.name.getText(this.sourceFile); + indexes.push(key); + break; + } + + if (ts.isPropertyAccessExpression(value.expression)) { + value = unwrapExpression(value.expression.expression); + continue; + } + + break; } } }; @@ -192,7 +219,7 @@ export class SchemaScanner { } private parseColumn(columnName: string, expression: ts.Expression): ColumnInfo { - let type = 'unknown'; + let type: ColumnInfo['type'] = 'unknown'; let nullable = true; let unique = false; let primaryKey = false; diff --git a/betterbase/packages/cli/test/context-generator.test.ts b/betterbase/packages/cli/test/context-generator.test.ts index 2499430..3a5d013 100644 --- a/betterbase/packages/cli/test/context-generator.test.ts +++ b/betterbase/packages/cli/test/context-generator.test.ts @@ -86,6 +86,7 @@ describe('ContextGenerator', () => { const context = await new ContextGenerator().generate(root); expect(context.tables).toEqual({}); + expect(context.routes).toEqual({}); } finally { rmSync(root, { recursive: true, force: true }); } @@ -100,6 +101,7 @@ describe('ContextGenerator', () => { const context = await new ContextGenerator().generate(root); expect(context.tables).toEqual({}); + expect(context.routes).toEqual({}); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/betterbase/packages/cli/test/route-scanner.test.ts b/betterbase/packages/cli/test/route-scanner.test.ts index 9f56991..a1a76bf 100644 --- a/betterbase/packages/cli/test/route-scanner.test.ts +++ b/betterbase/packages/cli/test/route-scanner.test.ts @@ -36,6 +36,8 @@ describe('RouteScanner', () => { expect(routes['/users']).toBeDefined(); expect(routes['/users'].length).toBe(2); + expect(routes['/users'][0].method).toBe('GET'); + expect(routes['/users'][1].method).toBe('POST'); expect(routes['/users'][0].requiresAuth).toBe(true); expect(routes['/users'][1].inputSchema).toBe('createUserSchema'); } finally { diff --git a/betterbase/packages/client/README.md b/betterbase/packages/client/README.md index cce3839..84ecf5a 100644 --- a/betterbase/packages/client/README.md +++ b/betterbase/packages/client/README.md @@ -1,3 +1,45 @@ -# @betterbase/client (Scaffold) +# @betterbase/client -Client SDK package placeholder. +TypeScript client for BetterBase backends. + +## Installation + +```bash +bun add @betterbase/client +``` + +## Usage + +```typescript +import { createClient } from '@betterbase/client'; + +const betterbase = createClient({ + url: 'http://localhost:3000', + key: 'optional-api-key', +}); + +const { data, error } = await betterbase + .from('users') + .select('*') + .eq('status', 'active') + .limit(10) + .execute(); + +await betterbase.auth.signUp({ + email: 'user@example.com', + password: 'password123', + name: 'John Doe', +}); + +betterbase + .realtime + .from('posts') + .on('INSERT', (payload) => { + console.log('New post:', payload.data); + }) + .subscribe(); +``` + +## API Reference + +See [documentation](https://betterbase.dev/docs/client) for full API reference. diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json new file mode 100644 index 0000000..7a886a7 --- /dev/null +++ b/betterbase/packages/client/package.json @@ -0,0 +1,50 @@ +{ + "name": "@betterbase/client", + "version": "0.1.0", + "description": "TypeScript client for BetterBase backends", + "license": "MIT", + "author": "BetterBase", + "repository": { + "type": "git", + "url": "https://github.com/weroperking/Betterbase.git" + }, + "engines": { + "node": ">=18", + "bun": ">=1.0.0" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "bun run src/build.ts", + "dev": "bun --watch run src/build.ts", + "test": "bun test", + "typecheck": "tsc --noEmit --project tsconfig.json", + "lint": "biome check src test", + "typecheck:test": "tsc --noEmit --project tsconfig.test.json" + }, + "keywords": [ + "betterbase", + "baas", + "backend", + "database", + "realtime" + ], + "files": [ + "dist", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.8", + "typescript": "^5.9.3", + "@biomejs/biome": "^1.9.4" + } +} diff --git a/betterbase/packages/client/src/auth.ts b/betterbase/packages/client/src/auth.ts new file mode 100644 index 0000000..808f4e9 --- /dev/null +++ b/betterbase/packages/client/src/auth.ts @@ -0,0 +1,183 @@ +import { z } from 'zod'; +import type { BetterBaseResponse } from './types'; +import { AuthError, NetworkError, ValidationError } from './errors'; + +export interface AuthCredentials { + email: string; + password: string; + name?: string; +} + +export interface User { + id: string; + email: string; + name: string | null; +} + +export interface Session { + token: string; + user: User; +} + +interface StorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +const credentialsSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +function getStorage(): Storage | null { + try { + if (typeof globalThis === 'undefined') { + return null; + } + + const storage = globalThis.localStorage; + return storage ?? null; + } catch { + return null; + } +} + +export class AuthClient { + constructor( + private url: string, + private headers: Record, + private onAuthStateChange?: (token: string | null) => void, + private fetchImpl: typeof fetch = fetch, + private storage: StorageAdapter | null = getStorage() + ) {} + + async signUp(credentials: AuthCredentials): Promise> { + const parsed = credentialsSchema.safeParse(credentials); + if (!parsed.success) { + return { data: null, error: new ValidationError('Invalid sign up credentials', parsed.error.format()) }; + } + + const endpoint = `${this.url}/auth/signup`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...credentials, ...parsed.data }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Signup failed' })); + return { data: null, error: new AuthError(error.error || 'Failed to sign up', error) }; + } + const session = (await response.json()) as Session; + this.storage?.setItem('betterbase_token', session.token); + this.onAuthStateChange?.(session.token); + return { data: session, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async signIn(credentials: Omit): Promise> { + const parsed = credentialsSchema.safeParse(credentials); + if (!parsed.success) { + return { data: null, error: new ValidationError('Invalid sign in credentials', parsed.error.format()) }; + } + + const endpoint = `${this.url}/auth/login`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(parsed.data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Login failed' })); + return { data: null, error: new AuthError(error.error || 'Invalid credentials', error) }; + } + const session = (await response.json()) as Session; + this.storage?.setItem('betterbase_token', session.token); + this.onAuthStateChange?.(session.token); + return { data: session, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async signOut(): Promise> { + const endpoint = `${this.url}/auth/logout`; + const token = this.storage?.getItem('betterbase_token') ?? null; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { + ...this.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + this.storage?.removeItem('betterbase_token'); + this.onAuthStateChange?.(null); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Logout failed' })); + return { data: null, error: new AuthError(error.error || 'Failed to sign out', error) }; + } + + return { data: null, error: null }; + } catch (error) { + this.storage?.removeItem('betterbase_token'); + this.onAuthStateChange?.(null); + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async getUser(): Promise> { + const endpoint = `${this.url}/auth/me`; + const token = this.storage?.getItem('betterbase_token') ?? null; + + if (!token) { + return { data: null, error: new AuthError('Not authenticated') }; + } + + try { + const response = await this.fetchImpl(endpoint, { + method: 'GET', + headers: { ...this.headers, Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to get user' })); + return { data: null, error: new AuthError(error.error || 'Failed to get user', error) }; + } + const result = await response.json(); + return { data: result.user, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + getToken(): string | null { + return this.storage?.getItem('betterbase_token') ?? null; + } + + setToken(token: string | null): void { + if (token) { + this.storage?.setItem('betterbase_token', token); + } else { + this.storage?.removeItem('betterbase_token'); + } + this.onAuthStateChange?.(token); + } +} diff --git a/betterbase/packages/client/src/build.ts b/betterbase/packages/client/src/build.ts new file mode 100644 index 0000000..d67ec45 --- /dev/null +++ b/betterbase/packages/client/src/build.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; + +const moduleDir = import.meta.dir; +const entrypoint = path.resolve(moduleDir, 'index.ts'); +const outdir = path.resolve(moduleDir, '../dist'); + +const esmResult = await Bun.build({ + entrypoints: [entrypoint], + outdir, + target: 'browser', + format: 'esm', + minify: false, + sourcemap: 'external', + naming: 'index.js', +}); + +if (!esmResult.success) { + console.error(`ESM build failed: ${esmResult.logs.map((log) => log.toString()).join('\n')}`); + process.exit(1); +} + +const cjsResult = await Bun.build({ + entrypoints: [entrypoint], + outdir, + target: 'node', + format: 'cjs', + minify: false, + sourcemap: 'external', + naming: 'index.cjs', +}); + +if (!cjsResult.success) { + console.error(`CJS build failed: ${cjsResult.logs.map((log) => log.toString()).join('\n')}`); + process.exit(1); +} + +const proc = Bun.spawn(['bunx', 'tsc', '--project', 'tsconfig.json', '--emitDeclarationOnly', '--outDir', outdir], { + cwd: path.resolve(moduleDir, '..'), + stdout: 'inherit', + stderr: 'inherit', +}); + +const exitCode = await proc.exited; +if (exitCode !== 0) { + console.error('TypeScript declaration generation failed'); + process.exit(1); +} + +console.log('✅ Build complete!'); diff --git a/betterbase/packages/client/src/client.ts b/betterbase/packages/client/src/client.ts new file mode 100644 index 0000000..158d711 --- /dev/null +++ b/betterbase/packages/client/src/client.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import type { BetterBaseConfig } from './types'; +import { QueryBuilder, type QueryBuilderOptions } from './query-builder'; +import { AuthClient } from './auth'; +import { RealtimeClient } from './realtime'; + +const BetterBaseConfigSchema = z.object({ + url: z.string().url(), + key: z.string().min(1).optional(), + schema: z.string().optional(), + fetch: z.function().optional(), + storage: z.object({ + getItem: z.function(), + setItem: z.function(), + removeItem: z.function(), + }).optional(), +}); + +export class BetterBaseClient { + private headers: Record; + private fetchImpl: typeof fetch; + private url: string; + public auth: AuthClient; + public realtime: RealtimeClient; + + constructor(config: BetterBaseConfig) { + const parsed = BetterBaseConfigSchema.parse(config); + this.url = parsed.url.replace(/\/$/, ''); + this.headers = { + 'Content-Type': 'application/json', + ...(parsed.key ? { 'X-BetterBase-Key': parsed.key } : {}), + }; + this.fetchImpl = parsed.fetch ?? fetch; + + this.auth = new AuthClient( + this.url, + this.headers, + (token) => { + if (token) { + this.headers.Authorization = `Bearer ${token}`; + } else { + delete this.headers.Authorization; + } + this.realtime.setToken(token); + }, + this.fetchImpl, + parsed.storage + ); + + this.realtime = new RealtimeClient(this.url, this.auth.getToken()); + + const token = this.auth.getToken(); + if (token) { + this.headers.Authorization = `Bearer ${token}`; + } + } + + from(table: string, options?: QueryBuilderOptions): QueryBuilder { + return new QueryBuilder(this.url, table, this.headers, this.fetchImpl, options); + } +} + +export function createClient(config: BetterBaseConfig): BetterBaseClient { + return new BetterBaseClient(config); +} diff --git a/betterbase/packages/client/src/errors.ts b/betterbase/packages/client/src/errors.ts new file mode 100644 index 0000000..28b2ffe --- /dev/null +++ b/betterbase/packages/client/src/errors.ts @@ -0,0 +1,29 @@ +export class BetterBaseError extends Error { + constructor( + message: string, + public code?: string, + public details?: unknown, + public status?: number + ) { + super(message); + this.name = this.constructor.name; + } +} + +export class NetworkError extends BetterBaseError { + constructor(message: string, details?: unknown) { + super(message, 'NETWORK_ERROR', details); + } +} + +export class AuthError extends BetterBaseError { + constructor(message: string, details?: unknown) { + super(message, 'AUTH_ERROR', details, 401); + } +} + +export class ValidationError extends BetterBaseError { + constructor(message: string, details?: unknown) { + super(message, 'VALIDATION_ERROR', details, 400); + } +} diff --git a/betterbase/packages/client/src/index.ts b/betterbase/packages/client/src/index.ts new file mode 100644 index 0000000..fc070a7 --- /dev/null +++ b/betterbase/packages/client/src/index.ts @@ -0,0 +1,15 @@ +export { createClient, BetterBaseClient } from './client'; +export { QueryBuilder } from './query-builder'; +export { AuthClient } from './auth'; +export { RealtimeClient } from './realtime'; +export { BetterBaseError, NetworkError, AuthError, ValidationError } from './errors'; + +export type { + BetterBaseConfig, + BetterBaseResponse, + QueryOptions, + RealtimeCallback, + RealtimeSubscription, +} from './types'; + +export type { User, Session, AuthCredentials } from './auth'; diff --git a/betterbase/packages/client/src/query-builder.ts b/betterbase/packages/client/src/query-builder.ts new file mode 100644 index 0000000..2a60b35 --- /dev/null +++ b/betterbase/packages/client/src/query-builder.ts @@ -0,0 +1,224 @@ +import { z } from 'zod'; +import type { BetterBaseResponse, QueryOptions } from './types'; +import { BetterBaseError, NetworkError, ValidationError } from './errors'; + +export interface QueryBuilderOptions { + singularKey?: string; +} + +const stringSchema = z.string().min(1); +const valuesSchema = z.array(z.unknown()); +const nonNegativeIntSchema = z.number().int().nonnegative(); + +export class QueryBuilder { + private filters: Record = {}; + private options: QueryOptions = {}; + private selectFields = '*'; + private executed = false; + + constructor( + private url: string, + private table: string, + private headers: Record, + private fetchImpl: typeof fetch = fetch, + private builderOptions: QueryBuilderOptions = {} + ) {} + + private assertMutable(): void { + if (this.executed) { + throw new Error('QueryBuilder instances are single-use; create a new one via from().'); + } + } + + select(fields = '*'): this { + this.assertMutable(); + this.selectFields = stringSchema.parse(fields); + return this; + } + + eq(column: string, value: unknown): this { + this.assertMutable(); + this.filters[stringSchema.parse(column)] = value; + return this; + } + + in(column: string, values: unknown[]): this { + this.assertMutable(); + const parsedValues = valuesSchema.parse(values); + this.filters[`${stringSchema.parse(column)}_in`] = JSON.stringify(parsedValues); + return this; + } + + limit(count: number): this { + this.assertMutable(); + this.options.limit = nonNegativeIntSchema.parse(count); + return this; + } + + offset(count: number): this { + this.assertMutable(); + this.options.offset = nonNegativeIntSchema.parse(count); + return this; + } + + order(column: string, direction: 'asc' | 'desc' = 'asc'): this { + this.assertMutable(); + this.options.orderBy = { column: stringSchema.parse(column), direction }; + return this; + } + + async execute(): Promise> { + if (this.executed) { + return { data: null, error: new ValidationError('QueryBuilder instances are single-use; create a new one via from().') }; + } + this.executed = true; + + const params = new URLSearchParams(); + params.append('select', this.selectFields); + + for (const [key, value] of Object.entries(this.filters)) { + params.append(key, String(value)); + } + + if (this.options.limit !== undefined) params.append('limit', String(this.options.limit)); + if (this.options.offset !== undefined) params.append('offset', String(this.options.offset)); + if (this.options.orderBy) { + params.append('sort', `${this.options.orderBy.column}:${this.options.orderBy.direction}`); + } + + const endpoint = `${this.url}/api/${this.table}?${params.toString()}`; + + try { + const response = await this.fetchImpl(endpoint, { + method: 'GET', + headers: this.headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + return { + data: null, + error: new BetterBaseError( + error.error || `Request failed with status ${response.status}`, + 'REQUEST_FAILED', + error, + response.status + ), + }; + } + + const result = await response.json(); + return { + data: result[this.table] || result.data || [], + error: null, + count: result.count, + pagination: result.pagination, + }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + private getSingularKey(): string { + return this.builderOptions.singularKey || (this.table.endsWith('s') ? this.table.slice(0, -1) : this.table); + } + + async single(id: string): Promise> { + const endpoint = `${this.url}/api/${this.table}/${id}`; + try { + const response = await this.fetchImpl(endpoint, { method: 'GET', headers: this.headers }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Not found' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Resource not found', 'NOT_FOUND', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.getSingularKey(); + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async insert(data: Partial): Promise> { + const endpoint = `${this.url}/api/${this.table}`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Insert failed' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Failed to insert record', 'INSERT_FAILED', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.getSingularKey(); + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async update(id: string, data: Partial): Promise> { + const endpoint = `${this.url}/api/${this.table}/${id}`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'PATCH', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Update failed' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Failed to update record', 'UPDATE_FAILED', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.getSingularKey(); + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async delete(id: string): Promise> { + const endpoint = `${this.url}/api/${this.table}/${id}`; + try { + const response = await this.fetchImpl(endpoint, { method: 'DELETE', headers: this.headers }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Delete failed' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Failed to delete record', 'DELETE_FAILED', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.getSingularKey(); + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } +} diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts new file mode 100644 index 0000000..b624fa1 --- /dev/null +++ b/betterbase/packages/client/src/realtime.ts @@ -0,0 +1,202 @@ +import type { RealtimeCallback, RealtimeSubscription } from './types'; + +type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'; + +interface SubscriberEntry { + callback: RealtimeCallback; + event: RealtimeEvent; + filter?: Record; +} + +export class RealtimeClient { + private ws: WebSocket | null = null; + private subscriptions = new Map>(); + private reconnectTimeout: ReturnType | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private subscriberSequence = 0; + private disabled = false; + private token: string | null; + + constructor(private url: string, token: string | null = null) { + this.token = token; + } + + setToken(token: string | null): void { + this.token = token; + } + + private scheduleReconnect(): void { + if (this.disabled || this.reconnectTimeout || this.subscriptions.size === 0) { + return; + } + + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + return; + } + + const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000); + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + this.reconnectAttempts += 1; + this.connect(); + }, delay); + } + + private sendSubscribe(table: string, filter?: Record): void { + if (this.disabled) return; + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); + } + } + + private sendUnsubscribe(table: string): void { + if (this.disabled) return; + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'unsubscribe', table })); + } + } + + private sendSubscribeAll(table: string): void { + const tableSubscribers = this.subscriptions.get(table); + if (!tableSubscribers || tableSubscribers.size === 0) { + return; + } + + for (const subscriber of tableSubscribers.values()) { + this.sendSubscribe(table, subscriber.filter); + } + } + + private connect(): void { + if (typeof WebSocket === 'undefined') { + this.disabled = true; + console.warn('[BetterBase] WebSocket is not available in this environment; realtime disabled'); + return; + } + + if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { + return; + } + + const baseUrl = this.url.replace(/^http/, 'ws') + '/ws'; + const wsUrl = this.token ? `${baseUrl}?token=${encodeURIComponent(this.token)}` : baseUrl; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + for (const table of this.subscriptions.keys()) { + this.sendSubscribeAll(table); + } + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data as string); + if (data.type !== 'update') return; + + const tableSubscribers = this.subscriptions.get(data.table); + if (!tableSubscribers) { + return; + } + + for (const subscriber of tableSubscribers.values()) { + if (subscriber.event === '*' || subscriber.event === data.event) { + subscriber.callback({ event: data.event, data: data.data, timestamp: data.timestamp }); + } + } + } catch { + // noop + } + }; + + this.ws.onerror = (error) => { + console.error('[BetterBase] WebSocket error:', error); + this.ws?.close(); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.ws = null; + this.scheduleReconnect(); + }; + + this.ws.onclose = () => { + this.ws = null; + this.scheduleReconnect(); + }; + } + + from(table: string): { + on: (event: RealtimeEvent, callback: RealtimeCallback) => { + subscribe: (filter?: Record) => RealtimeSubscription; + }; + } { + return { + on: (event, callback) => ({ + subscribe: (filter) => { + if (!this.disabled) { + this.connect(); + } + + const tableSubscribers = this.subscriptions.get(table) ?? new Map(); + const id = `${table}:${this.subscriberSequence++}`; + + tableSubscribers.set(id, { + event, + filter, + callback: (payload) => callback(payload as Parameters[0]), + }); + + this.subscriptions.set(table, tableSubscribers); + if (!this.disabled) { + this.sendSubscribe(table, filter); + } + + return { + unsubscribe: () => { + const currentSubscribers = this.subscriptions.get(table); + if (!currentSubscribers) { + return; + } + + currentSubscribers.delete(id); + + if (currentSubscribers.size === 0) { + this.subscriptions.delete(table); + if (!this.disabled) { + this.sendUnsubscribe(table); + } + + if (this.subscriptions.size === 0 && !this.disabled) { + this.disconnect(); + } + + return; + } + + this.subscriptions.set(table, currentSubscribers); + }, + }; + }, + }), + }; + } + + disconnect(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + this.ws?.close(); + this.ws = null; + this.subscriptions.clear(); + this.reconnectAttempts = 0; + } +} diff --git a/betterbase/packages/client/src/types.ts b/betterbase/packages/client/src/types.ts new file mode 100644 index 0000000..b11b24c --- /dev/null +++ b/betterbase/packages/client/src/types.ts @@ -0,0 +1,40 @@ +import type { BetterBaseError } from './errors'; + +export interface BetterBaseConfig { + url: string; + key?: string; + schema?: string; + fetch?: typeof fetch; + storage?: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + }; +} + +export interface QueryOptions { + limit?: number; + offset?: number; + orderBy?: { column: string; direction: 'asc' | 'desc' }; +} + +export interface BetterBaseResponse { + data: T | null; + error: BetterBaseError | null; + count?: number; + pagination?: { + limit: number; + offset: number; + hasMore: boolean; + }; +} + +export interface RealtimeSubscription { + unsubscribe: () => void; +} + +export type RealtimeCallback = (payload: { + event: 'INSERT' | 'UPDATE' | 'DELETE'; + data: T; + timestamp: string; +}) => void; diff --git a/betterbase/packages/client/test/client.test.ts b/betterbase/packages/client/test/client.test.ts new file mode 100644 index 0000000..0603c6c --- /dev/null +++ b/betterbase/packages/client/test/client.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { createClient } from '../src'; + +afterEach(() => { + mock.restore(); +}); + +describe('@betterbase/client', () => { + test('creates client with config', () => { + const client = createClient({ + url: 'http://localhost:3000', + key: 'test-key', + }); + + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + expect(client.realtime).toBeDefined(); + }); + + test('from creates query builder', () => { + const client = createClient({ url: 'http://localhost:3000' }); + const query = client.from('users'); + + expect(query).toBeDefined(); + expect(query.select).toBeDefined(); + expect(query.eq).toBeDefined(); + }); + + test('execute sends chained query with key header', async () => { + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toContain('/api/users?'); + expect(String(input)).toContain('select=id%2Cemail'); + expect(String(input)).toContain('email=test%40example.com'); + expect(init?.method).toBe('GET'); + expect((init?.headers as Record)['X-BetterBase-Key']).toBe('test-key'); + return new Response(JSON.stringify({ users: [] }), { status: 200 }); + }); + + const client = createClient({ url: 'http://localhost:3000', key: 'test-key', fetch: fetchMock as typeof fetch }); + const res = await client.from('users').select('id,email').eq('email', 'test@example.com').execute(); + + expect(res.error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('execute sends simple request', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + expect(String(input)).toBe('http://localhost:3000/api/users?select=*'); + return new Response(JSON.stringify({ users: [{ id: '1' }] }), { status: 200 }); + }); + + const client = createClient({ url: 'http://localhost:3000', fetch: fetchMock as typeof fetch }); + const res = await client.from<{ id: string }>('users').execute(); + + expect(res.error).toBeNull(); + expect(res.data).toEqual([{ id: '1' }]); + }); +}); diff --git a/betterbase/packages/client/tsconfig.json b/betterbase/packages/client/tsconfig.json new file mode 100644 index 0000000..dc3b91d --- /dev/null +++ b/betterbase/packages/client/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "lib": [ + "ES2022", + "DOM" + ], + "types": [ + "bun" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "test/**/*" + ] +} diff --git a/betterbase/packages/client/tsconfig.test.json b/betterbase/packages/client/tsconfig.test.json new file mode 100644 index 0000000..1209859 --- /dev/null +++ b/betterbase/packages/client/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "noEmit": true + }, + "include": [ + "src/**/*", + "test/**/*" + ] +} diff --git a/betterbase/templates/base/.gitignore b/betterbase/templates/base/.gitignore new file mode 100644 index 0000000..2cef73e --- /dev/null +++ b/betterbase/templates/base/.gitignore @@ -0,0 +1,3 @@ +.env +.env.* +!.env.example diff --git a/betterbase/templates/base/README.md b/betterbase/templates/base/README.md index 8d481e3..eb1a6ff 100644 --- a/betterbase/templates/base/README.md +++ b/betterbase/templates/base/README.md @@ -22,6 +22,7 @@ src/ validation.ts lib/ env.ts + realtime.ts index.ts betterbase.config.ts drizzle.config.ts @@ -38,3 +39,9 @@ drizzle.config.ts - Start production server: `bun run start` Environment variables are validated in `src/lib/env.ts` (`NODE_ENV`, `PORT`, `DB_PATH`). + + +## Realtime + +The template includes WebSocket realtime support at `GET /ws` using `src/lib/realtime.ts`. +Clients should provide an auth token (Bearer header or `?token=` query) before subscribing. diff --git a/betterbase/templates/base/package.json b/betterbase/templates/base/package.json index c74eee0..5d0970c 100644 --- a/betterbase/templates/base/package.json +++ b/betterbase/templates/base/package.json @@ -12,8 +12,9 @@ }, "dependencies": { "hono": "^4.6.10", - "zod": "^3.23.8", - "drizzle-orm": "^0.44.5" + "zod": "^4.0.0", + "drizzle-orm": "^0.44.5", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@types/bun": "^1.3.9", diff --git a/betterbase/templates/base/src/db/migrate.ts b/betterbase/templates/base/src/db/migrate.ts index 2bdd9bf..1d64b72 100644 --- a/betterbase/templates/base/src/db/migrate.ts +++ b/betterbase/templates/base/src/db/migrate.ts @@ -1,10 +1,10 @@ import { Database } from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; -import { DEFAULT_DB_PATH } from '../lib/env'; +import { env } from '../lib/env'; try { - const sqlite = new Database(DEFAULT_DB_PATH, { create: true }); + const sqlite = new Database(env.DB_PATH, { create: true }); const db = drizzle(sqlite); migrate(db, { migrationsFolder: './drizzle' }); diff --git a/betterbase/templates/base/src/index.ts b/betterbase/templates/base/src/index.ts index b8f9534..8ed7caa 100644 --- a/betterbase/templates/base/src/index.ts +++ b/betterbase/templates/base/src/index.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { upgradeWebSocket } from 'hono/bun'; +import { upgradeWebSocket, websocket } from 'hono/bun'; import { env } from './lib/env'; import { realtime } from './lib/realtime'; import { registerRoutes } from './routes'; @@ -8,9 +8,19 @@ const app = new Hono(); app.get( '/ws', - upgradeWebSocket(() => ({ + upgradeWebSocket((c) => { + const authHeaderToken = c.req.header('authorization')?.replace(/^Bearer\s+/i, ''); + // Prefer Authorization header. Query token is compatibility fallback and should be short-lived in production. + const queryToken = c.req.query('token'); + const token = authHeaderToken ?? queryToken; + + if (!authHeaderToken && queryToken) { + console.warn('WebSocket auth using query token fallback; prefer header/cookie/subprotocol in production.'); + } + + return { onOpen(_event, ws) { - realtime.handleConnection(ws.raw); + realtime.handleConnection(ws.raw, token); }, onMessage(event, ws) { const message = typeof event.data === 'string' ? event.data : event.data.toString(); @@ -19,13 +29,15 @@ app.get( onClose(_event, ws) { realtime.handleClose(ws.raw); }, - })), + }; + }), ); registerRoutes(app); const server = Bun.serve({ fetch: app.fetch, + websocket, port: env.PORT, development: env.NODE_ENV === 'development', }); diff --git a/betterbase/templates/base/src/lib/realtime.ts b/betterbase/templates/base/src/lib/realtime.ts index ace9c71..0d7ccc1 100644 --- a/betterbase/templates/base/src/lib/realtime.ts +++ b/betterbase/templates/base/src/lib/realtime.ts @@ -1,4 +1,6 @@ import type { ServerWebSocket } from 'bun'; +import deepEqual from 'fast-deep-equal'; +import { z } from 'zod'; export interface Subscription { table: string; @@ -7,6 +9,8 @@ export interface Subscription { interface Client { ws: ServerWebSocket; + userId: string; + claims: string[]; subscriptions: Map; } @@ -18,6 +22,17 @@ interface RealtimeUpdatePayload { timestamp: string; } +interface RealtimeConfig { + maxClients: number; + maxSubscriptionsPerClient: number; + maxSubscribersPerTable: number; +} + +const messageSchema = z.union([ + z.object({ type: z.literal('subscribe'), table: z.string().min(1).max(255), filter: z.record(z.string(), z.unknown()).optional() }), + z.object({ type: z.literal('unsubscribe'), table: z.string().min(1).max(255) }), +]); + const realtimeLogger = { debug: (message: string): void => console.debug(`[realtime] ${message}`), info: (message: string): void => console.info(`[realtime] ${message}`), @@ -27,38 +42,90 @@ const realtimeLogger = { export class RealtimeServer { private clients = new Map, Client>(); private tableSubscribers = new Map>>(); + private config: RealtimeConfig; - handleConnection(ws: ServerWebSocket): void { - realtimeLogger.info('Client connected'); + constructor(config?: Partial) { + if (process.env.NODE_ENV !== 'development' && process.env.ENABLE_DEV_AUTH !== 'true') { + realtimeLogger.warn('Realtime auth verifier is not configured; dev token parser is disabled. Configure a real verifier for production.'); + } + + this.config = { + maxClients: 1000, + maxSubscriptionsPerClient: 50, + maxSubscribersPerTable: 500, + ...config, + }; + } + + authenticate(token: string | undefined): { userId: string; claims: string[] } | null { + if (!token || !token.trim()) return null; + + const allowDevAuth = process.env.NODE_ENV === 'development' || process.env.ENABLE_DEV_AUTH === 'true'; + if (!allowDevAuth) { + return null; + } + + const [userId, rawClaims] = token.trim().split(':', 2); + if (!userId) return null; + + const claims = rawClaims ? rawClaims.split(',').map((claim) => claim.trim()).filter(Boolean) : []; + return { userId, claims }; + } + + authorize(userId: string, claims: string[], table: string): boolean { + return Boolean(userId) && (claims.includes('realtime:*') || claims.includes(`realtime:${table}`)); + } + + handleConnection(ws: ServerWebSocket, token: string | undefined): boolean { + if (this.clients.size >= this.config.maxClients) { + realtimeLogger.warn('Rejecting realtime connection: max clients reached'); + this.safeSend(ws, { error: 'Server is busy. Try again later.' }); + ws.close(1013, 'Server busy'); + return false; + } + + const identity = this.authenticate(token); + if (!identity) { + realtimeLogger.warn('Rejecting unauthenticated realtime connection'); + this.safeSend(ws, { error: 'Unauthorized websocket connection' }); + ws.close(1008, 'Unauthorized'); + return false; + } + + realtimeLogger.info(`Client connected (${identity.userId})`); this.clients.set(ws, { ws, + userId: identity.userId, + claims: identity.claims, subscriptions: new Map(), }); + + return true; } handleMessage(ws: ServerWebSocket, rawMessage: string): void { - try { - const data = JSON.parse(rawMessage) as { type?: string; table?: string; filter?: Record }; + let parsedJson: unknown; - if (!data.type || !data.table) { - this.safeSend(ws, { error: 'Message must include type and table' }); - return; - } - - switch (data.type) { - case 'subscribe': - this.subscribe(ws, data.table, data.filter); - break; - case 'unsubscribe': - this.unsubscribe(ws, data.table); - break; - default: - this.safeSend(ws, { error: 'Unknown message type' }); - break; - } + try { + parsedJson = JSON.parse(rawMessage); } catch { this.safeSend(ws, { error: 'Invalid message format' }); + return; + } + + const result = messageSchema.safeParse(parsedJson); + if (!result.success) { + this.safeSend(ws, { error: 'Invalid message format', details: result.error.format() }); + return; + } + + const data = result.data; + if (data.type === 'subscribe') { + this.subscribe(ws, data.table, data.filter); + return; } + + this.unsubscribe(ws, data.table); } handleClose(ws: ServerWebSocket): void { @@ -85,8 +152,6 @@ export class RealtimeServer { return; } - const initialCount = subscribers.size; - const payload: RealtimeUpdatePayload = { type: 'update', table, @@ -97,7 +162,8 @@ export class RealtimeServer { const message = JSON.stringify(payload); - for (const ws of [...subscribers]) { + const subs = Array.from(subscribers); + for (const ws of subs) { const client = this.clients.get(ws); const subscription = client?.subscriptions.get(table); if (!this.matchesFilter(subscription?.filter, data)) { @@ -109,30 +175,42 @@ export class RealtimeServer { this.handleClose(ws); } } - - realtimeLogger.debug(`Broadcasted ${event} on ${table} to ${initialCount} clients`); } private subscribe(ws: ServerWebSocket, table: string, filter?: Record): void { const client = this.clients.get(ws); if (!client) { + this.safeSend(ws, { error: 'Unauthorized client' }); + ws.close(1008, 'Unauthorized'); return; } - client.subscriptions.set(table, { table, filter }); + if (!this.authorize(client.userId, client.claims, table)) { + realtimeLogger.warn(`Subscription denied for ${client.userId} on ${table}`); + this.safeSend(ws, { error: 'Forbidden subscription' }); + return; + } - if (!this.tableSubscribers.has(table)) { - this.tableSubscribers.set(table, new Set()); + const existingSubscription = client.subscriptions.has(table); + if (!existingSubscription && client.subscriptions.size >= this.config.maxSubscriptionsPerClient) { + realtimeLogger.warn(`Subscription limit reached for ${client.userId}`); + this.safeSend(ws, { error: 'Subscription limit reached' }); + return; } - this.tableSubscribers.get(table)?.add(ws); + const tableSet = this.tableSubscribers.get(table) ?? new Set>(); + const alreadyInTableSet = tableSet.has(ws); + if (!alreadyInTableSet && tableSet.size >= this.config.maxSubscribersPerTable) { + realtimeLogger.warn(`Table subscriber cap reached for ${table}`); + this.safeSend(ws, { error: 'Table subscription limit reached' }); + return; + } - this.safeSend(ws, { - type: 'subscribed', - table, - filter, - }); + client.subscriptions.set(table, { table, filter }); + tableSet.add(ws); + this.tableSubscribers.set(table, tableSet); + this.safeSend(ws, { type: 'subscribed', table, filter }); realtimeLogger.debug(`Client subscribed to ${table}`); } @@ -150,10 +228,7 @@ export class RealtimeServer { this.tableSubscribers.delete(table); } - this.safeSend(ws, { - type: 'unsubscribed', - table, - }); + this.safeSend(ws, { type: 'unsubscribed', table }); } private matchesFilter(filter: Record | undefined, payload: unknown): boolean { @@ -166,7 +241,7 @@ export class RealtimeServer { } const data = payload as Record; - return Object.entries(filter).every(([key, value]) => data[key] === value); + return Object.entries(filter).every(([key, value]) => deepEqual(data[key], value)); } private safeSend(ws: ServerWebSocket, payload: object | string): boolean {