From e030affecd1b61022da04977e70d0edd8c01f77a Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 03:11:38 +0200 Subject: [PATCH 1/4] feat(client): scaffold @betterbase/client sdk package --- betterbase/bun.lock | 175 ++++++++++++++++ betterbase/packages/client/README.md | 46 ++++- betterbase/packages/client/package.json | 37 ++++ betterbase/packages/client/src/auth.ts | 155 ++++++++++++++ betterbase/packages/client/src/build.ts | 49 +++++ betterbase/packages/client/src/client.ts | 49 +++++ betterbase/packages/client/src/errors.ts | 29 +++ betterbase/packages/client/src/index.ts | 15 ++ .../packages/client/src/query-builder.ts | 191 ++++++++++++++++++ betterbase/packages/client/src/realtime.ts | 128 ++++++++++++ betterbase/packages/client/src/types.ts | 35 ++++ .../packages/client/test/client.test.ts | 24 +++ betterbase/packages/client/tsconfig.json | 12 ++ 13 files changed, 943 insertions(+), 2 deletions(-) create mode 100644 betterbase/bun.lock create mode 100644 betterbase/packages/client/package.json create mode 100644 betterbase/packages/client/src/auth.ts create mode 100644 betterbase/packages/client/src/build.ts create mode 100644 betterbase/packages/client/src/client.ts create mode 100644 betterbase/packages/client/src/errors.ts create mode 100644 betterbase/packages/client/src/index.ts create mode 100644 betterbase/packages/client/src/query-builder.ts create mode 100644 betterbase/packages/client/src/realtime.ts create mode 100644 betterbase/packages/client/src/types.ts create mode 100644 betterbase/packages/client/test/client.test.ts create mode 100644 betterbase/packages/client/tsconfig.json 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/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..f5a2935 --- /dev/null +++ b/betterbase/packages/client/package.json @@ -0,0 +1,37 @@ +{ + "name": "@betterbase/client", + "version": "0.1.0", + "description": "TypeScript client for BetterBase backends", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "bun run src/build.ts", + "dev": "bun run src/build.ts --watch", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "betterbase", + "baas", + "backend", + "database", + "realtime" + ], + "files": [ + "dist", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.9", + "typescript": "^5.9.3" + } +} diff --git a/betterbase/packages/client/src/auth.ts b/betterbase/packages/client/src/auth.ts new file mode 100644 index 0000000..1ddb51a --- /dev/null +++ b/betterbase/packages/client/src/auth.ts @@ -0,0 +1,155 @@ +import type { BetterBaseResponse } from './types'; +import { AuthError, NetworkError } 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; +} + +function getStorage(): Storage | null { + if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) { + return globalThis.localStorage; + } + return null; +} + +export class AuthClient { + constructor( + private url: string, + private headers: Record, + private onAuthStateChange?: (token: string | null) => void, + private fetchImpl: typeof fetch = fetch + ) {} + + async signUp(credentials: AuthCredentials): Promise> { + 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), + }); + 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; + getStorage()?.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 endpoint = `${this.url}/auth/login`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + 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; + getStorage()?.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 = getStorage()?.getItem('betterbase_token') ?? null; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { + ...this.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + getStorage()?.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) { + getStorage()?.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 = getStorage()?.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 getStorage()?.getItem('betterbase_token') ?? null; + } + + setToken(token: string | null): void { + const storage = getStorage(); + if (token) { + storage?.setItem('betterbase_token', token); + } else { + 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..f22a625 --- /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); + 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); + process.exit(1); +} + +const proc = Bun.spawn(['bunx', 'tsc', '--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..1cc5ac3 --- /dev/null +++ b/betterbase/packages/client/src/client.ts @@ -0,0 +1,49 @@ +import type { BetterBaseConfig } from './types'; +import { QueryBuilder } from './query-builder'; +import { AuthClient } from './auth'; +import { RealtimeClient } from './realtime'; + +export class BetterBaseClient { + private headers: Record; + private fetchImpl: typeof fetch; + private url: string; + public auth: AuthClient; + public realtime: RealtimeClient; + + constructor(config: BetterBaseConfig) { + this.url = config.url.replace(/\/$/, ''); + this.headers = { + 'Content-Type': 'application/json', + ...(config.key ? { 'X-BetterBase-Key': config.key } : {}), + }; + this.fetchImpl = config.fetch ?? fetch; + + this.auth = new AuthClient( + this.url, + this.headers, + (token) => { + if (token) { + this.headers.Authorization = `Bearer ${token}`; + } else { + delete this.headers.Authorization; + } + }, + this.fetchImpl + ); + + this.realtime = new RealtimeClient(this.url); + + const token = this.auth.getToken(); + if (token) { + this.headers.Authorization = `Bearer ${token}`; + } + } + + from(table: string): QueryBuilder { + return new QueryBuilder(this.url, table, this.headers, this.fetchImpl); + } +} + +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..a270bd8 --- /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 = 'BetterBaseError'; + } +} + +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..62e478e --- /dev/null +++ b/betterbase/packages/client/src/query-builder.ts @@ -0,0 +1,191 @@ +import type { BetterBaseResponse, QueryOptions } from './types'; +import { BetterBaseError, NetworkError } from './errors'; + +export class QueryBuilder { + private filters: Record = {}; + private options: QueryOptions = {}; + private selectFields = '*'; + + constructor( + private url: string, + private table: string, + private headers: Record, + private fetchImpl: typeof fetch = fetch + ) {} + + select(fields = '*'): this { + this.selectFields = fields; + return this; + } + + eq(column: string, value: unknown): this { + this.filters[column] = value; + return this; + } + + in(column: string, values: unknown[]): this { + this.filters[`${column}_in`] = values; + return this; + } + + limit(count: number): this { + this.options.limit = count; + return this; + } + + offset(count: number): this { + this.options.offset = count; + return this; + } + + order(column: string, direction: 'asc' | 'desc' = 'asc'): this { + this.options.orderBy = { column, direction }; + return this; + } + + async execute(): Promise> { + 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), + }; + } + } + + 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.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + 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.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + 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.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + 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.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + 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..63fe2da --- /dev/null +++ b/betterbase/packages/client/src/realtime.ts @@ -0,0 +1,128 @@ +import type { RealtimeCallback, RealtimeSubscription } from './types'; + +type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'; + +export class RealtimeClient { + private ws: WebSocket | null = null; + private subscriptions = new Map>(); + private reconnectTimeout: ReturnType | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + + constructor(private url: string) {} + + private connect(): void { + if (typeof WebSocket === 'undefined') { + return; + } + + if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { + return; + } + + const wsUrl = this.url.replace(/^http/, 'ws') + '/ws'; + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + for (const table of this.subscriptions.keys()) { + this.sendSubscribe(table); + } + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data as string); + if (data.type !== 'update') return; + + const callbacks = this.subscriptions.get(data.table); + if (callbacks) { + for (const callback of callbacks) { + callback({ event: data.event, data: data.data, timestamp: data.timestamp }); + } + } + } catch { + // noop + } + }; + + this.ws.onclose = () => { + this.ws = null; + if (this.reconnectAttempts < this.maxReconnectAttempts && this.subscriptions.size > 0) { + const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000); + this.reconnectTimeout = setTimeout(() => { + this.reconnectAttempts++; + this.connect(); + }, delay); + } + }; + } + + private sendSubscribe(table: string, filter?: Record): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); + } + } + + private sendUnsubscribe(table: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'unsubscribe', table })); + } + } + + from(table: string): { + on: (event: RealtimeEvent, callback: RealtimeCallback) => { + subscribe: (filter?: Record) => RealtimeSubscription; + }; + } { + return { + on: (event, callback) => ({ + subscribe: (filter) => { + this.connect(); + + const wrappedCallback: RealtimeCallback = (payload) => { + if (event === '*' || payload.event === event) { + callback(payload as Parameters[0]); + } + }; + + if (!this.subscriptions.has(table)) { + this.subscriptions.set(table, new Set()); + } + + this.subscriptions.get(table)?.add(wrappedCallback); + this.sendSubscribe(table, filter); + + return { + unsubscribe: () => { + const callbacks = this.subscriptions.get(table); + callbacks?.delete(wrappedCallback); + + if (callbacks && callbacks.size === 0) { + this.subscriptions.delete(table); + this.sendUnsubscribe(table); + + if (this.subscriptions.size === 0) { + this.disconnect(); + } + } + }, + }; + }, + }), + }; + } + + 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..c6784be --- /dev/null +++ b/betterbase/packages/client/src/types.ts @@ -0,0 +1,35 @@ +import type { BetterBaseError } from './errors'; + +export interface BetterBaseConfig { + url: string; + key?: string; + schema?: string; + fetch?: typeof fetch; +} + +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..bc05995 --- /dev/null +++ b/betterbase/packages/client/test/client.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test'; +import { createClient } from '../src'; + +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(); + }); +}); diff --git a/betterbase/packages/client/tsconfig.json b/betterbase/packages/client/tsconfig.json new file mode 100644 index 0000000..40e831f --- /dev/null +++ b/betterbase/packages/client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "lib": ["ES2022", "DOM"], + "types": ["bun"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} From 2a923cc03e61a95ecd7b08d5e4b4e63c2c73c757 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 03:52:33 +0200 Subject: [PATCH 2/4] fix(client): harden storage access and preserve realtime filters --- betterbase/packages/client/package.json | 2 +- betterbase/packages/client/src/auth.ts | 12 +++-- betterbase/packages/client/src/build.ts | 2 +- betterbase/packages/client/src/realtime.ts | 48 ++++++++++++++----- betterbase/packages/client/tsconfig.json | 4 +- betterbase/packages/client/tsconfig.test.json | 9 ++++ 6 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 betterbase/packages/client/tsconfig.test.json diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index f5a2935..3b7f219 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -17,7 +17,7 @@ "build": "bun run src/build.ts", "dev": "bun run src/build.ts --watch", "test": "bun test", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --project tsconfig.test.json" }, "keywords": [ "betterbase", diff --git a/betterbase/packages/client/src/auth.ts b/betterbase/packages/client/src/auth.ts index 1ddb51a..7461eee 100644 --- a/betterbase/packages/client/src/auth.ts +++ b/betterbase/packages/client/src/auth.ts @@ -19,10 +19,16 @@ export interface Session { } function getStorage(): Storage | null { - if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) { - return globalThis.localStorage; + try { + if (typeof globalThis === 'undefined') { + return null; + } + + const storage = globalThis.localStorage; + return storage ?? null; + } catch { + return null; } - return null; } export class AuthClient { diff --git a/betterbase/packages/client/src/build.ts b/betterbase/packages/client/src/build.ts index f22a625..a856ac6 100644 --- a/betterbase/packages/client/src/build.ts +++ b/betterbase/packages/client/src/build.ts @@ -34,7 +34,7 @@ if (!cjsResult.success) { process.exit(1); } -const proc = Bun.spawn(['bunx', 'tsc', '--emitDeclarationOnly', '--outDir', outdir], { +const proc = Bun.spawn(['bunx', 'tsc', '--project', 'tsconfig.json', '--emitDeclarationOnly', '--outDir', outdir], { cwd: path.resolve(moduleDir, '..'), stdout: 'inherit', stderr: 'inherit', diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts index 63fe2da..ffdcf40 100644 --- a/betterbase/packages/client/src/realtime.ts +++ b/betterbase/packages/client/src/realtime.ts @@ -2,9 +2,15 @@ import type { RealtimeCallback, RealtimeSubscription } from './types'; type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'; +interface TableSubscription { + callbacks: Set; + filter?: Record; + refCount: number; +} + export class RealtimeClient { private ws: WebSocket | null = null; - private subscriptions = new Map>(); + private subscriptions = new Map(); private reconnectTimeout: ReturnType | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; @@ -26,8 +32,8 @@ export class RealtimeClient { this.ws.onopen = () => { this.reconnectAttempts = 0; - for (const table of this.subscriptions.keys()) { - this.sendSubscribe(table); + for (const [table, subscription] of this.subscriptions.entries()) { + this.sendSubscribe(table, subscription.filter); } }; @@ -36,9 +42,9 @@ export class RealtimeClient { const data = JSON.parse(event.data as string); if (data.type !== 'update') return; - const callbacks = this.subscriptions.get(data.table); - if (callbacks) { - for (const callback of callbacks) { + const subscription = this.subscriptions.get(data.table); + if (subscription) { + for (const callback of subscription.callbacks) { callback({ event: data.event, data: data.data, timestamp: data.timestamp }); } } @@ -87,25 +93,41 @@ export class RealtimeClient { } }; - if (!this.subscriptions.has(table)) { - this.subscriptions.set(table, new Set()); + const subscription = this.subscriptions.get(table) ?? { + callbacks: new Set(), + refCount: 0, + filter, + }; + + subscription.callbacks.add(wrappedCallback); + subscription.refCount += 1; + + if (filter !== undefined) { + subscription.filter = filter; } - this.subscriptions.get(table)?.add(wrappedCallback); - this.sendSubscribe(table, filter); + this.subscriptions.set(table, subscription); + this.sendSubscribe(table, subscription.filter); return { unsubscribe: () => { - const callbacks = this.subscriptions.get(table); - callbacks?.delete(wrappedCallback); + const current = this.subscriptions.get(table); + if (!current) { + return; + } + + current.callbacks.delete(wrappedCallback); + current.refCount = Math.max(0, current.refCount - 1); - if (callbacks && callbacks.size === 0) { + if (current.refCount === 0 || current.callbacks.size === 0) { this.subscriptions.delete(table); this.sendUnsubscribe(table); if (this.subscriptions.size === 0) { this.disconnect(); } + } else { + this.subscriptions.set(table, current); } }, }; diff --git a/betterbase/packages/client/tsconfig.json b/betterbase/packages/client/tsconfig.json index 40e831f..c4c4354 100644 --- a/betterbase/packages/client/tsconfig.json +++ b/betterbase/packages/client/tsconfig.json @@ -7,6 +7,6 @@ "lib": ["ES2022", "DOM"], "types": ["bun"] }, - "include": ["src/**/*", "test/**/*"], - "exclude": ["node_modules", "dist"] + "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..f3e3655 --- /dev/null +++ b/betterbase/packages/client/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +} From 6d8b0b048256610194938ade89c1b3da59dcec11 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 04:25:58 +0200 Subject: [PATCH 3/4] fix(client): support multi-filter realtime subscriptions --- betterbase/packages/client/package.json | 15 ++- betterbase/packages/client/src/realtime.ts | 148 +++++++++++++-------- 2 files changed, 104 insertions(+), 59 deletions(-) diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index 3b7f219..fdd5544 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -2,20 +2,29 @@ "name": "@betterbase/client", "version": "0.1.0", "description": "TypeScript client for BetterBase backends", + "license": "MIT", + "author": "BetterBase", + "repository": { + "type": "git", + "url": "https://github.com/betterbase/betterbase.git" + }, + "engines": { + "node": ">=18" + }, "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", - "types": "./dist/index.d.ts" + "require": "./dist/index.cjs" } }, "scripts": { "build": "bun run src/build.ts", - "dev": "bun run src/build.ts --watch", + "dev": "bun --watch run src/build.ts", "test": "bun test", "typecheck": "tsc --project tsconfig.test.json" }, diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts index ffdcf40..181fc68 100644 --- a/betterbase/packages/client/src/realtime.ts +++ b/betterbase/packages/client/src/realtime.ts @@ -2,24 +2,67 @@ import type { RealtimeCallback, RealtimeSubscription } from './types'; type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'; -interface TableSubscription { - callbacks: Set; +interface SubscriberEntry { + callback: RealtimeCallback; + event: RealtimeEvent; filter?: Record; - refCount: number; } export class RealtimeClient { private ws: WebSocket | null = null; - private subscriptions = new Map(); + private subscriptions = new Map>(); private reconnectTimeout: ReturnType | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; + private subscriberSequence = 0; constructor(private url: string) {} + private scheduleReconnect(): void { + if (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.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); + } + } + + private sendUnsubscribe(table: string): void { + 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') { - return; + const message = '[BetterBase] WebSocket is not available in this environment'; + console.warn(message); + throw new Error(message); } if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { @@ -27,13 +70,17 @@ export class RealtimeClient { } const wsUrl = this.url.replace(/^http/, 'ws') + '/ws'; - this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.reconnectAttempts = 0; - for (const [table, subscription] of this.subscriptions.entries()) { - this.sendSubscribe(table, subscription.filter); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + for (const table of this.subscriptions.keys()) { + this.sendSubscribeAll(table); } }; @@ -42,10 +89,14 @@ export class RealtimeClient { const data = JSON.parse(event.data as string); if (data.type !== 'update') return; - const subscription = this.subscriptions.get(data.table); - if (subscription) { - for (const callback of subscription.callbacks) { - callback({ event: data.event, data: data.data, timestamp: data.timestamp }); + 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 { @@ -53,28 +104,20 @@ export class RealtimeClient { } }; - this.ws.onclose = () => { + this.ws.onerror = (error) => { + console.error('[BetterBase] WebSocket error:', error); this.ws = null; - if (this.reconnectAttempts < this.maxReconnectAttempts && this.subscriptions.size > 0) { - const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000); - this.reconnectTimeout = setTimeout(() => { - this.reconnectAttempts++; - this.connect(); - }, delay); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; } + this.scheduleReconnect(); }; - } - private sendSubscribe(table: string, filter?: Record): void { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); - } - } - - private sendUnsubscribe(table: string): void { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: 'unsubscribe', table })); - } + this.ws.onclose = () => { + this.ws = null; + this.scheduleReconnect(); + }; } from(table: string): { @@ -87,48 +130,41 @@ export class RealtimeClient { subscribe: (filter) => { this.connect(); - const wrappedCallback: RealtimeCallback = (payload) => { - if (event === '*' || payload.event === event) { - callback(payload as Parameters[0]); - } - }; + const tableSubscribers = this.subscriptions.get(table) ?? new Map(); + const id = `${table}:${this.subscriberSequence++}`; - const subscription = this.subscriptions.get(table) ?? { - callbacks: new Set(), - refCount: 0, + tableSubscribers.set(id, { + event, filter, - }; + callback: (payload) => callback(payload as Parameters[0]), + }); - subscription.callbacks.add(wrappedCallback); - subscription.refCount += 1; - - if (filter !== undefined) { - subscription.filter = filter; - } - - this.subscriptions.set(table, subscription); - this.sendSubscribe(table, subscription.filter); + this.subscriptions.set(table, tableSubscribers); + this.sendSubscribe(table, filter); return { unsubscribe: () => { - const current = this.subscriptions.get(table); - if (!current) { + const currentSubscribers = this.subscriptions.get(table); + if (!currentSubscribers) { return; } - current.callbacks.delete(wrappedCallback); - current.refCount = Math.max(0, current.refCount - 1); + currentSubscribers.delete(id); + + this.sendUnsubscribe(table); - if (current.refCount === 0 || current.callbacks.size === 0) { + if (currentSubscribers.size === 0) { this.subscriptions.delete(table); - this.sendUnsubscribe(table); if (this.subscriptions.size === 0) { this.disconnect(); } - } else { - this.subscriptions.set(table, current); + + return; } + + this.subscriptions.set(table, currentSubscribers); + this.sendSubscribeAll(table); }, }; }, From 987d1854f95c21a1784089f7eb13e11bae7f03da Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 04:33:48 +0200 Subject: [PATCH 4/4] fix(client): avoid resubscribe churn on unsubscribe --- betterbase/packages/client/package.json | 4 +++- betterbase/packages/client/src/realtime.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index fdd5544..5e6fe17 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -26,7 +26,9 @@ "build": "bun run src/build.ts", "dev": "bun --watch run src/build.ts", "test": "bun test", - "typecheck": "tsc --project tsconfig.test.json" + "typecheck": "tsc --noEmit --project tsconfig.json", + "lint": "tsc --noEmit --project tsconfig.test.json", + "typecheck:test": "tsc --project tsconfig.test.json" }, "keywords": [ "betterbase", diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts index 181fc68..8c5fce3 100644 --- a/betterbase/packages/client/src/realtime.ts +++ b/betterbase/packages/client/src/realtime.ts @@ -151,10 +151,9 @@ export class RealtimeClient { currentSubscribers.delete(id); - this.sendUnsubscribe(table); - if (currentSubscribers.size === 0) { this.subscriptions.delete(table); + this.sendUnsubscribe(table); if (this.subscriptions.size === 0) { this.disconnect(); @@ -164,7 +163,6 @@ export class RealtimeClient { } this.subscriptions.set(table, currentSubscribers); - this.sendSubscribeAll(table); }, }; },