diff --git a/bun.lock b/bun.lock index acb4682..425b8a9 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@sinclair/typebox": "^0.34.49", "@tanstack/react-query": "^5.100.10", "ajv": "^8.20.0", + "drizzle-orm": "^0.45.2", "es-toolkit": "^1.46.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -30,6 +31,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", + "drizzle-kit": "^0.31.10", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -141,12 +143,70 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -407,6 +467,8 @@ "browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "call-bind": ["call-bind@1.0.9", "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.9.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], @@ -515,6 +577,10 @@ "dot-prop": ["dot-prop@5.3.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.353", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="], @@ -547,6 +613,8 @@ "es-toolkit": ["es-toolkit@1.46.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -1017,8 +1085,12 @@ "sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split2": ["split2@4.2.0", "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], @@ -1073,6 +1145,8 @@ "tslib": ["tslib@2.3.1", "https://registry.npmmirror.com/tslib/-/tslib-2.3.1.tgz", {}, "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="], + "tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="], + "type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -1165,6 +1239,8 @@ "@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], @@ -1237,12 +1313,58 @@ "thread-stream/real-require": ["real-require@1.0.0", "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], @@ -1267,6 +1389,58 @@ "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], diff --git a/docs/development/README.md b/docs/development/README.md index 2bc3bc1..b736153 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -71,16 +71,18 @@ ## 目录边界 -| 目录 | 约定 | -| ------------------- | -------------------------------------------------------- | -| `src/server/` | Bun 后端代码,不能 import src/web/,HTML import 集成除外 | -| `src/web/` | React 前端,不能 import src/server/ 运行时实现 | -| `src/shared/` | 前后端共享 TypeScript 类型 | -| `scripts/` | 独立运行脚本,可 import 项目源码 | -| `tests/` | 测试目录,结构镜像 src/ | -| `docs/user/` | 用户使用、配置、部署和排障文档 | -| `docs/development/` | 架构、后端、前端、发布开发文档 | -| `openspec/` | OpenSpec 变更管理与规格文档 | +| 目录 | 约定 | +| ------------------- | -------------------------------------------------------------------- | +| `src/server/` | Bun 后端代码,不能 import src/web/,HTML import 集成除外 | +| `src/server/db/` | SQLite 数据库模块,包含 schema、connection、migration 和 data access | +| `src/web/` | React 前端,不能 import src/server/ 运行时实现 | +| `src/shared/` | 前后端共享 TypeScript 类型 | +| `scripts/` | 独立运行脚本,可 import 项目源码 | +| `drizzle/` | Drizzle Kit 生成的 SQL migration 文件(开发期产出) | +| `tests/` | 测试目录,结构镜像 src/ | +| `docs/user/` | 用户使用、配置、部署和排障文档 | +| `docs/development/` | 架构、后端、前端、发布开发文档 | +| `openspec/` | OpenSpec 变更管理与规格文档 | ## 文档影响分析 diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 3b23987..d359d70 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -9,9 +9,15 @@ ```text src/ server/ - bootstrap.ts 统一启动引导(loadServerConfig -> startServer) + bootstrap.ts 统一启动引导(loadServerConfig -> DB 初始化 -> startServer) config.ts CLI 参数解析与配置文件加载 facade config/ 配置解析模块(types、issues、variables、normalizer、schema) + db/ SQLite 数据库模块 + schema.ts Drizzle ORM schema 定义 + connection.ts 数据库连接与 PRAGMA 设置 + load-migrations.ts 从文件系统加载 migration SQL + migrate.ts migration 执行器(备份 + 事务应用) + projects.ts 项目数据访问函数 dev.ts 开发模式启动入口 main.ts 生产模式启动入口 server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由) @@ -49,9 +55,13 @@ dev.ts / main.ts -> bootstrap({ configPath, mode }) -> loadServerConfig(configPath) -> createRuntimeLogger(config.logging) - -> startServer({ config, logger }) + -> 确保 dataDir 就绪(mkdirSync) + -> 加载 migrations(生产:嵌入的 bytes;开发:磁盘 drizzle/ 目录) + -> createDatabase(dataDir) + -> runMigrations(db, migrations)(pending migration 存在时先备份 DB) + -> startServer({ config, logger, db }) -> logger 记录启动成功 - -> SIGINT/SIGTERM -> logger.flush() -> exit + -> SIGINT/SIGTERM -> db.close() -> logger.flush() -> exit ``` ## HTTP 请求流程 @@ -77,15 +87,16 @@ Request ## 主要模块职责 -| 模块 | 职责 | -| ------------------------- | ---------------------------------------- | -| `src/server/bootstrap.ts` | 统一启动引导和 shutdown 编排 | -| `src/server/server.ts` | Bun HTTP server 和 routes 注册 | -| `src/server/routes/` | API handler,按端点拆分 | -| `src/server/config/` | 配置解析模块(types、variables、schema) | -| `src/web/` | React 前端 | -| `src/shared/api.ts` | 前后端共享 API 类型 | -| `src/shared/app.ts` | 应用全局常量 | +| 模块 | 职责 | +| ------------------------- | --------------------------------------------- | +| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化和 shutdown 编排 | +| `src/server/server.ts` | Bun HTTP server 和 routes 注册 | +| `src/server/routes/` | API handler,按端点拆分 | +| `src/server/db/` | SQLite 连接、schema、migration 和 data access | +| `src/server/config/` | 配置解析模块(types、variables、schema) | +| `src/web/` | React 前端 | +| `src/shared/api.ts` | 前后端共享 API 类型 | +| `src/shared/app.ts` | 应用全局常量 | ## 更新触发条件 diff --git a/docs/development/backend.md b/docs/development/backend.md index 96dbb23..4969851 100644 --- a/docs/development/backend.md +++ b/docs/development/backend.md @@ -6,13 +6,13 @@ ## 库使用优先级 -| 优先级 | 来源 | 典型用途 | -| ------ | ------------ | ---------------------------------------- | -| 1 | Bun 内置 API | Bun.serve、Bun.file、Bun.YAML、Bun.spawn | -| 2 | es-toolkit | 类型判断、深度比较、并发控制 | -| 3 | 标准 Web API | Headers、fetch、AbortController | -| 4 | 主流三方库 | pino、@sinclair/typebox、ajv | -| 5 | 自行实现 | 仅在以上都无法满足时 | +| 优先级 | 来源 | 典型用途 | +| ------ | ------------ | ---------------------------------------------------- | +| 1 | Bun 内置 API | Bun.serve、Bun.file、Bun.YAML、Bun.spawn、bun:sqlite | +| 2 | es-toolkit | 类型判断、深度比较、并发控制 | +| 3 | 标准 Web API | Headers、fetch、AbortController | +| 4 | 主流三方库 | pino、@sinclair/typebox、ajv、drizzle-orm | +| 5 | 自行实现 | 仅在以上都无法满足时 | 新增依赖前必须先检查上述每一层是否已有可用方案。 @@ -41,6 +41,29 @@ middleware.ts 提供 API 参数校验函数: - validatePagination(pageParam, pageSizeParam, mode) — 校验分页参数 - validateTimeRange(from, to, mode) — 校验时间范围参数 +## 数据库 + +项目使用 SQLite 作为存储后端,通过 bun:sqlite + Drizzle ORM 实现类型安全的数据访问。 + +### schema 定义 + +`src/server/db/schema.ts` 使用 Drizzle ORM 定义表结构,列名使用 snake_case,TypeScript 类型使用 camelCase,Drizzle schema 负责映射。 + +### 数据库连接 + +`src/server/db/connection.ts` 的 `createDatabase(dataDir, logger)` 打开 `/alfred.db`,设置 PRAGMA(foreign_keys=ON、journal_mode=WAL、busy_timeout=5000)。 + +### migration 机制 + +- 开发期:使用 `drizzle-kit generate` 从 TS schema 生成 SQL migration 文件到 `drizzle/` 目录 +- 生产期:构建时将 `drizzle/*.sql` 嵌入可执行文件,启动时自动应用 pending migrations +- 每次 migration 前自动备份现有 DB 到 `/backups/alfred-.db` +- migration 在事务中执行,失败则回滚并停止启动 + +### 数据访问 + +`src/server/db/projects.ts` 提供项目数据访问函数,输入输出使用 `src/shared/api.ts` 的类型。函数内部使用 Drizzle query builder 包装 `bun:sqlite` Database。 + ## 类型规范 - 共享类型以 src/shared/api.ts 为唯一源头 diff --git a/docs/development/release.md b/docs/development/release.md index 70c2819..74f4557 100644 --- a/docs/development/release.md +++ b/docs/development/release.md @@ -41,9 +41,10 @@ bun run build 构建流程: ```text -1. Vite build -> dist/web/ -2. Code generation -> .build/static-assets.ts + .build/server-entry.ts -3. Bun compile -> dist/alfred +1. Vite build -> dist/web/ +2. Code generation -> .build/static-assets.ts + .build/migrations-data.ts + .build/server-entry.ts +3. Bun compile -> dist/alfred +4. Cleanup -> 清理 .build/ 临时目录 ``` 构建参数: diff --git a/docs/user/config.md b/docs/user/config.md index fe01f67..aadbd55 100644 --- a/docs/user/config.md +++ b/docs/user/config.md @@ -36,9 +36,9 @@ server: ## server.storage -| 字段 | 类型 | 说明 | -| ------- | ------ | --------------------------------------------------- | -| dataDir | string | 数据目录,默认 ./data,相对路径基于配置文件目录解析 | +| 字段 | 类型 | 说明 | +| ------- | ------ | ------------------------------------------------------------------------------------------------------------------- | +| dataDir | string | 数据目录,默认 ./data,相对路径基于配置文件目录解析。目录下存储 alfred.db(SQLite 数据库)和 backups/(数据库备份) | ## server.logging diff --git a/docs/user/deploy.md b/docs/user/deploy.md index 10b6988..28e952a 100644 --- a/docs/user/deploy.md +++ b/docs/user/deploy.md @@ -28,10 +28,18 @@ scripts/build.ts 执行三步流水线: ``` - Vite 构建前端资源到 dist/web/,自动 code splitting(vendor-react、vendor-tdesign、vendor-chart) -- Code generation 扫描 dist/web/ 生成 import with { type: "file" } 声明,将资源嵌入 binary +- Code generation 扫描 dist/web/ 生成 static-assets.ts,读取 drizzle/\*.sql 生成 migrations-data.ts,生成 server-entry.ts 串联入口 +- migrations-data.ts 将 migration SQL 嵌入 binary,生产部署无需额外携带 migration 文件 - Bun compile 以 .build/server-entry.ts 为入口编译最终可执行文件 - .build/ 临时目录在构建完成后自动清理 +## 运行时数据存储 + +- 应用使用 SQLite 存储数据,数据库文件位于 `/alfred.db` +- 启动时自动应用 pending database migrations(无需手动迁移) +- 每次 migration 执行前自动备份现有数据库到 `/backups/` +- 数据库文件使用 WAL 模式,支持并发读写 + ## 产物 | 产物 | 用途 | diff --git a/docs/user/usage.md b/docs/user/usage.md index 59a64b7..e3683b1 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -29,3 +29,12 @@ bun run dev config.yaml - [配置文件](config.md) — 了解 YAML 结构、变量语法和配置字段 - [部署文档](deploy.md) — 生产构建和运行方式 - [开发文档](../development/README.md) — 开发规范、架构和质量门禁 + +## 功能介绍 + +| 功能 | 路径 | 说明 | +| -------- | ----------- | ------------------------------------ | +| 仪表盘 | `/` | 应用总览,展示运行时元信息 | +| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 | +| 用户管理 | `/users` | 页面建设中 | +| 系统设置 | `/settings` | 页面建设中 | diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..54ccf9e --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + out: "./drizzle", + schema: "./src/server/db/schema.ts", +}); diff --git a/drizzle/0000_cheerful_switch.sql b/drizzle/0000_cheerful_switch.sql new file mode 100644 index 0000000..243065b --- /dev/null +++ b/drizzle/0000_cheerful_switch.sql @@ -0,0 +1,16 @@ +CREATE TABLE `projects` ( + `archived_at` text, + `created_at` text NOT NULL, + `description` text DEFAULT '' NOT NULL, + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL CHECK (status IN ('active', 'archived')), + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `projects_name_unique` ON `projects` (`name`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `schema_migrations` ( + `applied_at` text NOT NULL, + `checksum` text NOT NULL, + `id` text PRIMARY KEY NOT NULL +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..87b7985 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,116 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7c940c6c-2dd6-4509-aad1-90aa48887cb9", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "projects": { + "name": "projects", + "columns": { + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_migrations": { + "name": "schema_migrations", + "columns": { + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..0730729 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1779873780188, + "tag": "0000_cheerful_switch", + "breakpoints": true + } + ] +} diff --git a/package.json b/package.json index adb70b2..79b8138 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", + "drizzle-kit": "^0.31.10", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -54,6 +55,7 @@ "@sinclair/typebox": "^0.34.49", "@tanstack/react-query": "^5.100.10", "ajv": "^8.20.0", + "drizzle-orm": "^0.45.2", "es-toolkit": "^1.46.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", diff --git a/scripts/build.ts b/scripts/build.ts index a7c597d..24e825d 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -26,7 +26,7 @@ async function build() { } async function bunCompile() { - console.log("Step 3/3: Bun compile..."); + console.log("Step 4/4: Bun compile..."); await rm(executablePath, { force: true }); const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"]; @@ -60,7 +60,7 @@ async function cleanup() { } async function codeGeneration() { - console.log("Step 2/3: Code generation..."); + console.log("Step 2/4: Code generation..."); await rm(buildDir, { force: true, recursive: true }); await Bun.write(join(buildDir, ".gitkeep"), ""); @@ -72,6 +72,9 @@ async function codeGeneration() { } validateVersion(version); + await generateMigrationsData(); + + console.log("Step 3/4: Generating static assets..."); const allFiles = await scanDir(distWebDir, "/"); const importLines: string[] = []; const fileEntries: string[] = []; @@ -115,13 +118,14 @@ async function codeGeneration() { `import { bootstrap } from "../src/server/bootstrap";`, `import { parseRuntimeArgs } from "../src/server/config";`, `import { createConsoleFallback } from "../src/server/logger";`, + `import { MIGRATIONS } from "./migrations-data";`, `import { staticAssets } from "./static-assets";`, "", `const APP_VERSION = "${version}" as const;`, "", `async function main() {`, ` const { configPath } = parseRuntimeArgs();`, - ` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`, + ` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`, `}`, "", `void main().catch((error) => {`, @@ -134,6 +138,43 @@ async function codeGeneration() { await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs); } +async function generateMigrationsData() { + const { createHash } = await import("node:crypto"); + const { readdirSync, readFileSync } = await import("node:fs"); + const migrationsDir = join(projectRoot, "drizzle"); + + let entries: string[]; + try { + entries = readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + } catch { + entries = []; + } + + const records = entries.map((filename) => { + const sql = readFileSync(join(migrationsDir, filename), "utf-8"); + const id = filename.replace(/\.sql$/, ""); + const checksum = createHash("sha256").update(sql).digest("hex").slice(0, 16); + return { checksum, id, sql: sql.trim() }; + }); + + const lines = [ + `import type { MigrationRecord } from "../src/server/db/load-migrations";`, + ``, + `export const MIGRATIONS: MigrationRecord[] = [`, + ...records.map( + (r) => + ` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`, + ), + `];`, + ``, + ].join("\n"); + + await writeFile(join(buildDir, "migrations-data.ts"), lines); + console.log(`Embedded ${records.length} migration(s)`); +} + async function scanDir(dir: string, prefix: string): Promise { const entries = await readdir(dir, { withFileTypes: true }); const paths: string[] = []; diff --git a/scripts/generate-migrations-data.ts b/scripts/generate-migrations-data.ts new file mode 100644 index 0000000..9f9ab23 --- /dev/null +++ b/scripts/generate-migrations-data.ts @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const projectRoot = resolve(import.meta.dir, ".."); +const migrationsDir = join(projectRoot, "drizzle"); +const outputPath = join(projectRoot, ".build", "migrations-data.ts"); + +function generateMigrationsData() { + let entries: string[]; + try { + entries = readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + } catch { + entries = []; + } + + const records = entries.map((filename) => { + const sql = readFileSync(join(migrationsDir, filename), "utf-8"); + const id = filename.replace(/\.sql$/, ""); + const checksum = createHash("sha256").update(sql).digest("hex").slice(0, 16); + return { checksum, id, sql: sql.trim() }; + }); + + const lines = [ + `import type { MigrationRecord } from "../src/server/db/load-migrations";`, + ``, + `export const MIGRATIONS: MigrationRecord[] = [`, + ...records.map( + (r) => + ` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`, + ), + `];`, + ``, + ].join("\n"); + + writeFileSync(outputPath, lines); + console.log(`Generated ${outputPath} with ${records.length} migration(s)`); +} + +generateMigrationsData(); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index d46cbb0..465b5fb 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -2,10 +2,12 @@ import { mkdirSync } from "node:fs"; import type { RuntimeMode } from "../shared/api"; import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types"; +import type { MigrationRecord } from "./db/load-migrations"; import type { Logger } from "./logger"; import type { StartServerOptions } from "./server"; import { loadServerConfig } from "./config"; +import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db"; import { createConsoleFallback, createRuntimeLogger } from "./logger"; import { startServer } from "./server"; @@ -19,6 +21,7 @@ export interface BootstrapDependencies { export interface BootstrapOptions { configPath: string; + migrations?: MigrationRecord[]; mode: RuntimeMode; staticAssets?: StartServerOptions["staticAssets"]; version?: string; @@ -59,8 +62,13 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr mkdirSync(config.dataDir, { recursive: true }); logger!.info({ dataDir: config.dataDir }, "数据目录就绪"); + const migrations = options.migrations ?? loadMigrationsFromDir(); + const db = createDatabase(config.dataDir, logger!); + runMigrations(db, migrations, config.dataDir, logger!); + const shutdown = () => { logger?.info("收到退出信号,开始优雅关闭"); + db.close(); logger?.flush(); exit(0); }; @@ -69,6 +77,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr serve({ config: { host: config.host, port: config.port }, + db, logger: logger!.child({ component: "server" }), mode: options.mode, staticAssets: options.staticAssets, diff --git a/src/server/db/connection.ts b/src/server/db/connection.ts new file mode 100644 index 0000000..5147f50 --- /dev/null +++ b/src/server/db/connection.ts @@ -0,0 +1,19 @@ +import Database from "bun:sqlite"; +import { join } from "node:path"; + +import type { Logger } from "../logger"; + +const DB_FILENAME = "alfred.db"; + +export function createDatabase(dataDir: string, logger: Logger): Database { + const dbPath = join(dataDir, DB_FILENAME); + const db = new Database(dbPath); + + db.exec("PRAGMA foreign_keys = ON"); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA busy_timeout = 5000"); + + logger.info({ dbPath }, "数据库连接初始化"); + + return db; +} diff --git a/src/server/db/index.ts b/src/server/db/index.ts new file mode 100644 index 0000000..f570c68 --- /dev/null +++ b/src/server/db/index.ts @@ -0,0 +1,4 @@ +export { createDatabase } from "./connection"; +export { loadMigrationsFromDir, type MigrationRecord } from "./load-migrations"; +export { runMigrations } from "./migrate"; +export { projects, schemaMigrations } from "./schema"; diff --git a/src/server/db/load-migrations.ts b/src/server/db/load-migrations.ts new file mode 100644 index 0000000..c2f3546 --- /dev/null +++ b/src/server/db/load-migrations.ts @@ -0,0 +1,30 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +export interface MigrationRecord { + checksum: string; + id: string; + sql: string; +} + +export function loadMigrationsFromDir(migrationsDir?: string): MigrationRecord[] { + const dir = migrationsDir ?? resolve(import.meta.dir, "../../../drizzle"); + + let entries: string[]; + try { + entries = readdirSync(dir) + .filter((f) => f.endsWith(".sql")) + .sort(); + } catch { + return []; + } + + return entries.map((filename) => { + const sql = readFileSync(join(dir, filename), "utf-8"); + const id = filename.replace(/\.sql$/, ""); + const checksum = createHash("sha256").update(sql).digest("hex").slice(0, 16); + + return { checksum, id, sql }; + }); +} diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts new file mode 100644 index 0000000..189aaa1 --- /dev/null +++ b/src/server/db/migrate.ts @@ -0,0 +1,67 @@ +import type Database from "bun:sqlite"; + +import { copyFileSync, existsSync, mkdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import type { Logger } from "../logger"; +import type { MigrationRecord } from "./load-migrations"; + +export function runMigrations(db: Database, migrations: MigrationRecord[], dataDir: string, logger: Logger): void { + if (migrations.length === 0) { + logger.info("数据库 schema 已是最新"); + return; + } + + db.exec( + "CREATE TABLE IF NOT EXISTS schema_migrations (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)", + ); + + const applied = getAppliedMigrationIds(db); + const pending = migrations.filter((m) => !applied.has(m.id)); + + if (pending.length === 0) { + logger.info("数据库 schema 已是最新"); + return; + } + + logger.info({ count: pending.length }, "发现待执行 migration"); + + const dbPath = join(dataDir, "alfred.db"); + if (needsBackup(dbPath)) { + backupDatabase(dbPath, dataDir, logger); + } + + const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)"); + + db.transaction(() => { + for (const migration of pending) { + logger.info({ id: migration.id }, "执行 migration"); + db.exec(migration.sql); + insertApplied.run(migration.id, migration.checksum, new Date().toISOString()); + } + })(); + + logger.info({ count: pending.length }, "migration 全部执行完成"); +} + +function backupDatabase(dbPath: string, dataDir: string, logger: Logger): void { + const backupsDir = join(dataDir, "backups"); + mkdirSync(backupsDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = join(backupsDir, `alfred-${timestamp}.db`); + + copyFileSync(dbPath, backupPath); + logger.info({ backupPath }, "数据库备份完成"); +} + +function getAppliedMigrationIds(db: Database): Set { + const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>; + return new Set(rows.map((r) => r.id)); +} + +function needsBackup(dbPath: string): boolean { + if (!existsSync(dbPath)) return false; + const stat = statSync(dbPath); + return stat.size > 0; +} diff --git a/src/server/db/projects.ts b/src/server/db/projects.ts new file mode 100644 index 0000000..66472b4 --- /dev/null +++ b/src/server/db/projects.ts @@ -0,0 +1,191 @@ +import type Database from "bun:sqlite"; + +import { and, desc, eq, like, or, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/bun-sqlite"; + +import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api"; + +import { projects } from "./schema"; + +export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { + const db = wrap(raw); + const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + if (!existing) return { error: "项目不存在", status: 404 }; + if (existing.status === "archived") return { error: "项目已归档", status: 409 }; + + const now = new Date().toISOString(); + db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run(); + + const updated = db.select().from(projects).where(eq(projects.id, id)).get(); + return { project: toProject(updated!) }; +} + +export function createProject( + raw: Database, + request: CreateProjectRequest, +): { error: string; status: number } | { project: Project } { + const db = wrap(raw); + const name = request.name.trim(); + if (!name) return { error: "项目名称不能为空", status: 400 }; + + const description = (request.description ?? "").trim(); + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + + try { + db.insert(projects) + .values({ + archivedAt: null, + createdAt: now, + description, + id, + name, + status: "active", + updatedAt: now, + }) + .run(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("UNIQUE constraint")) { + return { error: "项目名称已存在", status: 409 }; + } + throw e; + } + + const row = db.select().from(projects).where(eq(projects.id, id)).get(); + return { project: toProject(row!) }; +} + +export function deleteProject(raw: Database, id: string): { error: string; status: number } | { success: true } { + const db = wrap(raw); + const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + if (!existing) return { error: "项目不存在", status: 404 }; + if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 }; + + db.delete(projects).where(eq(projects.id, id)).run(); + return { success: true }; +} + +export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { + const db = wrap(raw); + const row = db.select().from(projects).where(eq(projects.id, id)).get(); + + if (!row) return { error: "项目不存在", status: 404 }; + return { project: toProject(row) }; +} + +export function listProjects( + raw: Database, + options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus }, +): { items: Project[]; page: number; pageSize: number; total: number } { + const db = wrap(raw); + const conditions = []; + + if (options.status) { + conditions.push(eq(projects.status, options.status)); + } + + if (options.keyword) { + const pattern = `%${options.keyword}%`; + conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const countResult = db + .select({ count: sql`count(*)` }) + .from(projects) + .where(where) + .get(); + + const total = Number(countResult?.count ?? 0); + + const rows = db + .select() + .from(projects) + .where(where) + .orderBy(desc(projects.createdAt)) + .limit(options.pageSize) + .offset((options.page - 1) * options.pageSize) + .all(); + + return { + items: rows.map(toProject), + page: options.page, + pageSize: options.pageSize, + total, + }; +} + +export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { + const db = wrap(raw); + const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + if (!existing) return { error: "项目不存在", status: 404 }; + if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 }; + + const now = new Date().toISOString(); + db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run(); + + const updated = db.select().from(projects).where(eq(projects.id, id)).get(); + return { project: toProject(updated!) }; +} + +export function updateProject( + raw: Database, + id: string, + request: UpdateProjectRequest, +): { error: string; status: number } | { project: Project } { + const db = wrap(raw); + const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + if (!existing) return { error: "项目不存在", status: 404 }; + if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 }; + + const name = request.name?.trim(); + if (name === "") return { error: "项目名称不能为空", status: 400 }; + + const updates: Partial = { + updatedAt: new Date().toISOString(), + }; + + if (name !== undefined && name !== existing.name) { + updates.name = name; + } + + const description = request.description?.trim(); + if (description !== undefined) { + updates.description = description; + } + + if (Object.keys(updates).length === 1 && updates.updatedAt) { + return { project: toProject(existing) }; + } + + try { + db.update(projects).set(updates).where(eq(projects.id, id)).run(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("UNIQUE constraint")) { + return { error: "项目名称已存在", status: 409 }; + } + throw e; + } + + const updated = db.select().from(projects).where(eq(projects.id, id)).get(); + return { project: toProject(updated!) }; +} + +function toProject(row: typeof projects.$inferSelect): Project { + return { + archivedAt: row.archivedAt, + createdAt: row.createdAt, + description: row.description, + id: row.id, + name: row.name, + status: row.status, + updatedAt: row.updatedAt, + }; +} + +function wrap(raw: Database) { + return drizzle(raw); +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts new file mode 100644 index 0000000..efc5329 --- /dev/null +++ b/src/server/db/schema.ts @@ -0,0 +1,19 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const projects = sqliteTable("projects", { + archivedAt: text("archived_at"), + createdAt: text("created_at").notNull(), + description: text("description").notNull().default(""), + id: text("id").primaryKey(), + name: text("name").notNull().unique(), + status: text("status", { enum: ["active", "archived"] }) + .notNull() + .default("active"), + updatedAt: text("updated_at").notNull(), +}); + +export const schemaMigrations = sqliteTable("schema_migrations", { + appliedAt: text("applied_at").notNull(), + checksum: text("checksum").notNull(), + id: text("id").primaryKey(), +}); diff --git a/src/server/routes/projects/archive.ts b/src/server/routes/projects/archive.ts new file mode 100644 index 0000000..6cd8998 --- /dev/null +++ b/src/server/routes/projects/archive.ts @@ -0,0 +1,22 @@ +import type Database from "bun:sqlite"; + +import type { RuntimeMode } from "../../../shared/api"; + +import { archiveProject } from "../../db/projects"; +import { createApiError, jsonResponse } from "../../helpers"; +import { validateIdParam } from "../../middleware"; + +export function handleArchiveProject(req: Request, db: Database, mode: RuntimeMode): Response { + const url = new URL(req.url); + const idStr = url.pathname.split("/")[3]; + + const validated = validateIdParam(idStr ?? "", mode); + if (validated instanceof Response) return validated; + + const result = archiveProject(db, validated.id); + if ("error" in result) { + return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); + } + + return jsonResponse(result, { mode }); +} diff --git a/src/server/routes/projects/create.ts b/src/server/routes/projects/create.ts new file mode 100644 index 0000000..b24f106 --- /dev/null +++ b/src/server/routes/projects/create.ts @@ -0,0 +1,26 @@ +import type Database from "bun:sqlite"; + +import type { CreateProjectRequest, RuntimeMode } from "../../../shared/api"; + +import { createProject } from "../../db/projects"; +import { createApiError, jsonResponse } from "../../helpers"; + +export async function handleCreateProject(req: Request, db: Database, mode: RuntimeMode): Promise { + let body: CreateProjectRequest; + try { + body = (await req.json()) as CreateProjectRequest; + } catch { + return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); + } + + if (!body.name || typeof body.name !== "string") { + return jsonResponse(createApiError("name is required", 400), { mode, status: 400 }); + } + + const result = createProject(db, body); + if ("error" in result) { + return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); + } + + return jsonResponse(result, { mode, status: 201 }); +} diff --git a/src/server/routes/projects/delete.ts b/src/server/routes/projects/delete.ts new file mode 100644 index 0000000..d44d6fb --- /dev/null +++ b/src/server/routes/projects/delete.ts @@ -0,0 +1,22 @@ +import type Database from "bun:sqlite"; + +import type { RuntimeMode } from "../../../shared/api"; + +import { deleteProject } from "../../db/projects"; +import { createApiError, jsonResponse } from "../../helpers"; +import { validateIdParam } from "../../middleware"; + +export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response { + const url = new URL(req.url); + const idStr = url.pathname.split("/")[3]; + + const validated = validateIdParam(idStr ?? "", mode); + if (validated instanceof Response) return validated; + + const result = deleteProject(db, validated.id); + if ("error" in result) { + return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); + } + + return new Response(null, { status: 204 }); +} diff --git a/src/server/routes/projects/get.ts b/src/server/routes/projects/get.ts new file mode 100644 index 0000000..4bc857b --- /dev/null +++ b/src/server/routes/projects/get.ts @@ -0,0 +1,22 @@ +import type Database from "bun:sqlite"; + +import type { RuntimeMode } from "../../../shared/api"; + +import { getProject } from "../../db/projects"; +import { jsonResponse } from "../../helpers"; +import { validateIdParam } from "../../middleware"; + +export function handleGetProject(req: Request, db: Database, mode: RuntimeMode): Response { + const url = new URL(req.url); + const idStr = url.pathname.split("/")[3]; + + const validated = validateIdParam(idStr ?? "", mode); + if (validated instanceof Response) return validated; + + const result = getProject(db, validated.id); + if ("error" in result) { + return jsonResponse({ error: result.error, status: result.status }, { mode, status: result.status }); + } + + return jsonResponse(result, { mode }); +} diff --git a/src/server/routes/projects/list.ts b/src/server/routes/projects/list.ts new file mode 100644 index 0000000..0759bff --- /dev/null +++ b/src/server/routes/projects/list.ts @@ -0,0 +1,31 @@ +import type Database from "bun:sqlite"; + +import type { RuntimeMode } from "../../../shared/api"; + +import { listProjects } from "../../db/projects"; +import { createApiError, jsonResponse } from "../../helpers"; +import { validatePagination } from "../../middleware"; + +export function handleListProjects(req: Request, db: Database, mode: RuntimeMode): Response { + const url = new URL(req.url); + const pageParam = url.searchParams.get("page"); + const pageSizeParam = url.searchParams.get("pageSize"); + const keyword = url.searchParams.get("keyword"); + const statusParam = url.searchParams.get("status"); + + const pagination = validatePagination(pageParam, pageSizeParam, mode); + if (pagination instanceof Response) return pagination; + + if (statusParam && statusParam !== "active" && statusParam !== "archived") { + return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 }); + } + + const result = listProjects(db, { + keyword: keyword ?? undefined, + page: pagination.page, + pageSize: pagination.pageSize, + status: (statusParam as "active" | "archived") ?? undefined, + }); + + return jsonResponse(result, { mode }); +} diff --git a/src/server/routes/projects/restore.ts b/src/server/routes/projects/restore.ts new file mode 100644 index 0000000..40b2ce6 --- /dev/null +++ b/src/server/routes/projects/restore.ts @@ -0,0 +1,22 @@ +import type Database from "bun:sqlite"; + +import type { RuntimeMode } from "../../../shared/api"; + +import { restoreProject } from "../../db/projects"; +import { createApiError, jsonResponse } from "../../helpers"; +import { validateIdParam } from "../../middleware"; + +export function handleRestoreProject(req: Request, db: Database, mode: RuntimeMode): Response { + const url = new URL(req.url); + const idStr = url.pathname.split("/")[3]; + + const validated = validateIdParam(idStr ?? "", mode); + if (validated instanceof Response) return validated; + + const result = restoreProject(db, validated.id); + if ("error" in result) { + return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); + } + + return jsonResponse(result, { mode }); +} diff --git a/src/server/routes/projects/update.ts b/src/server/routes/projects/update.ts new file mode 100644 index 0000000..2099ac4 --- /dev/null +++ b/src/server/routes/projects/update.ts @@ -0,0 +1,33 @@ +import type Database from "bun:sqlite"; + +import type { RuntimeMode, UpdateProjectRequest } from "../../../shared/api"; + +import { updateProject } from "../../db/projects"; +import { createApiError, jsonResponse } from "../../helpers"; +import { validateIdParam } from "../../middleware"; + +export async function handleUpdateProject(req: Request, db: Database, mode: RuntimeMode): Promise { + const url = new URL(req.url); + const idStr = url.pathname.split("/")[3]; + + const validated = validateIdParam(idStr ?? "", mode); + if (validated instanceof Response) return validated; + + let body: UpdateProjectRequest; + try { + body = (await req.json()) as UpdateProjectRequest; + } catch { + return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); + } + + if (!body.name && !body.description && body.name !== "" && body.description !== "") { + return jsonResponse(createApiError("At least one of name or description is required", 400), { mode, status: 400 }); + } + + const result = updateProject(db, validated.id, body); + if ("error" in result) { + return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); + } + + return jsonResponse(result, { mode }); +} diff --git a/src/server/server.ts b/src/server/server.ts index 1907e37..86c1400 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,3 +1,5 @@ +import type Database from "bun:sqlite"; + import type { RuntimeMode } from "../shared/api"; import type { Logger } from "./logger"; import type { StaticAssets } from "./static"; @@ -9,6 +11,7 @@ import { readAppVersion } from "./version"; export interface StartServerOptions { config: { host: string; port: number }; + db: Database; logger: Logger; mode: RuntimeMode; staticAssets?: StaticAssets; @@ -16,7 +19,7 @@ export interface StartServerOptions { } export function startServer(options: StartServerOptions) { - const { config, logger, mode, staticAssets, version } = options; + const { config, db, logger, mode, staticAssets, version } = options; const resolveVersion = (): Promise => { if (version) return Promise.resolve(version); @@ -40,6 +43,42 @@ export function startServer(options: StartServerOptions) { return handleMeta(mode, resolvedVersion); }, }, + "/api/projects": { + GET: async (req) => { + const { handleListProjects } = await import("./routes/projects/list"); + return handleListProjects(req, db, mode); + }, + POST: async (req) => { + const { handleCreateProject } = await import("./routes/projects/create"); + return handleCreateProject(req, db, mode); + }, + }, + "/api/projects/:id": { + DELETE: async (req) => { + const { handleDeleteProject } = await import("./routes/projects/delete"); + return handleDeleteProject(req, db, mode); + }, + GET: async (req) => { + const { handleGetProject } = await import("./routes/projects/get"); + return handleGetProject(req, db, mode); + }, + PATCH: async (req) => { + const { handleUpdateProject } = await import("./routes/projects/update"); + return handleUpdateProject(req, db, mode); + }, + }, + "/api/projects/:id/archive": { + POST: async (req) => { + const { handleArchiveProject } = await import("./routes/projects/archive"); + return handleArchiveProject(req, db, mode); + }, + }, + "/api/projects/:id/restore": { + POST: async (req) => { + const { handleRestoreProject } = await import("./routes/projects/restore"); + return handleRestoreProject(req, db, mode); + }, + }, }, }); diff --git a/src/shared/api.ts b/src/shared/api.ts index 694ab31..b9fec82 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -3,6 +3,11 @@ export interface ApiErrorResponse { status: number; } +export interface CreateProjectRequest { + description?: string; + name: string; +} + export interface MetaResponse { ok: true; service: string; @@ -10,9 +15,33 @@ export interface MetaResponse { version: string; } -export type RuntimeMode = "development" | "production" | "test"; - // ========================================== // 在此定义你的业务类型 // 前后端共享的类型都放在这个文件中 // ========================================== + +export interface Project { + archivedAt: null | string; + createdAt: string; + description: string; + id: string; + name: string; + status: ProjectStatus; + updatedAt: string; +} + +export interface ProjectListResponse { + items: Project[]; + page: number; + pageSize: number; + total: number; +} + +export type ProjectStatus = "active" | "archived"; + +export type RuntimeMode = "development" | "production" | "test"; + +export interface UpdateProjectRequest { + description?: string; + name?: string; +} diff --git a/src/web/hooks/use-projects.ts b/src/web/hooks/use-projects.ts new file mode 100644 index 0000000..23fe92c --- /dev/null +++ b/src/web/hooks/use-projects.ts @@ -0,0 +1,158 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { + CreateProjectRequest, + Project, + ProjectListResponse, + ProjectStatus, + UpdateProjectRequest, +} from "../../shared/api"; + +const PROJECTS_KEY = ["projects"] as const; + +export function useArchiveProject() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: archiveProject, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); + }, + }); +} + +export function useCreateProject() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createProject, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); + }, + }); +} + +export function useDeleteProject() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteProject, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); + }, + }); +} + +export function useProject(id: string) { + return useQuery({ + enabled: !!id, + queryFn: () => fetchProject(id), + queryKey: [...PROJECTS_KEY, "detail", id], + }); +} + +export function useProjectList(params: { keyword?: string; page?: number; pageSize?: number; status?: ProjectStatus }) { + return useQuery({ + queryFn: () => fetchProjectList(params), + queryKey: [...PROJECTS_KEY, "list", params], + }); +} + +export function useRestoreProject() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: restoreProject, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); + }, + }); +} + +export function useUpdateProject() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (args: { data: UpdateProjectRequest; id: string }) => updateProject(args.id, args.data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }); + }, + }); +} + +async function archiveProject(id: string): Promise { + const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json() as Promise; +} + +async function createProject(data: CreateProjectRequest): Promise { + const response = await fetch("/api/projects", { + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json() as Promise; +} + +async function deleteProject(id: string): Promise { + const response = await fetch(`/api/projects/${id}`, { method: "DELETE" }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } +} + +async function fetchProject(id: string): Promise { + const response = await fetch(`/api/projects/${id}`); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json() as Promise; +} + +async function fetchProjectList(params: { + keyword?: string; + page?: number; + pageSize?: number; + status?: ProjectStatus; +}): Promise { + const searchParams = new URLSearchParams(); + if (params.page) searchParams.set("page", String(params.page)); + if (params.pageSize) searchParams.set("pageSize", String(params.pageSize)); + if (params.keyword) searchParams.set("keyword", params.keyword); + if (params.status) searchParams.set("status", params.status); + const qs = searchParams.toString(); + const url = `/api/projects${qs ? `?${qs}` : ""}`; + const response = await fetch(url); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json() as Promise; +} + +async function restoreProject(id: string): Promise { + const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json() as Promise; +} + +async function updateProject(id: string, data: UpdateProjectRequest): Promise { + const response = await fetch(`/api/projects/${id}`, { + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + method: "PATCH", + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + return response.json() as Promise; +} diff --git a/src/web/menu.tsx b/src/web/menu.tsx index 62d0c23..4d31c97 100644 --- a/src/web/menu.tsx +++ b/src/web/menu.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from "react"; import type { MenuValue } from "tdesign-react"; import { createElement } from "react"; -import { DashboardIcon, SettingIcon, UserIcon } from "tdesign-icons-react"; +import { DashboardIcon, FolderIcon, SettingIcon, UserIcon } from "tdesign-icons-react"; export interface MenuItemConfig { icon: ReactElement; @@ -13,6 +13,7 @@ export interface MenuItemConfig { export const MENU_ITEMS: readonly MenuItemConfig[] = [ { icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" }, + { icon: createElement(FolderIcon), label: "项目管理", path: "/projects", value: "projects" }, { icon: createElement(UserIcon), label: "用户管理", path: "/users", value: "users" }, { icon: createElement(SettingIcon), label: "系统设置", path: "/settings", value: "settings" }, ] as const; diff --git a/src/web/pages/projects/index.tsx b/src/web/pages/projects/index.tsx new file mode 100644 index 0000000..513ee56 --- /dev/null +++ b/src/web/pages/projects/index.tsx @@ -0,0 +1,301 @@ +import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react"; + +import { useState } from "react"; +import { AddIcon, BrowseIcon, DeleteIcon, EditIcon, SearchIcon } from "tdesign-icons-react"; +import { + Button, + Dialog, + Form, + Input, + Loading, + MessagePlugin, + Popconfirm, + Space, + Table, + Tabs, + Tag, + Textarea, +} from "tdesign-react"; + +import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../../shared/api"; + +import { + useArchiveProject, + useCreateProject, + useDeleteProject, + useProjectList, + useRestoreProject, + useUpdateProject, +} from "../../hooks/use-projects"; + +const { useForm } = Form; + +const STATUS_TABS = [ + { label: "进行中", value: "active" }, + { label: "已归档", value: "archived" }, +]; + +export function ProjectsPage() { + const [tabValue, setTabValue] = useState("active"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [keyword, setKeyword] = useState(""); + const [searchValue, setSearchValue] = useState(""); + + const [dialogVisible, setDialogVisible] = useState(false); + const [editingProject, setEditingProject] = useState(null); + const [form] = useForm(); + + const { data, isLoading } = useProjectList({ keyword: keyword || undefined, page, pageSize, status: tabValue }); + const createMutation = useCreateProject(); + const updateMutation = useUpdateProject(); + const archiveMutation = useArchiveProject(); + const restoreMutation = useRestoreProject(); + const deleteMutation = useDeleteProject(); + + const handleSearch = () => { + setKeyword(searchValue); + setPage(1); + }; + + const handleSearchKeydown = (_value: string, context: { e: React.KeyboardEvent }) => { + if (context.e.key === "Enter") { + handleSearch(); + } + }; + + const handleTabChange = (value: number | string) => { + setTabValue(value as ProjectStatus); + setPage(1); + }; + + const openCreateDialog = () => { + setEditingProject(null); + setDialogVisible(true); + }; + + const openEditDialog = (project: Project) => { + setEditingProject(project); + setDialogVisible(true); + }; + + const handleDialogConfirm = async () => { + const valid = await form?.validate?.(); + if (valid !== true) return; + + const values = form?.getFieldsValue?.(true) as { description?: string; name: string }; + try { + if (editingProject) { + const reqData: UpdateProjectRequest = {}; + if (values.name !== editingProject.name) reqData.name = values.name; + if ((values.description ?? "") !== (editingProject.description ?? "")) reqData.description = values.description; + await updateMutation.mutateAsync({ data: reqData, id: editingProject.id }); + void MessagePlugin.success("项目已更新"); + } else { + const reqData: CreateProjectRequest = { description: values.description, name: values.name }; + await createMutation.mutateAsync(reqData); + void MessagePlugin.success("项目已创建"); + } + setDialogVisible(false); + } catch (err) { + void MessagePlugin.error((err as Error).message); + } + }; + + const handleArchive = async (id: string) => { + try { + await archiveMutation.mutateAsync(id); + void MessagePlugin.success("项目已归档"); + } catch (err) { + void MessagePlugin.error((err as Error).message); + } + }; + + const handleRestore = async (id: string) => { + try { + await restoreMutation.mutateAsync(id); + void MessagePlugin.success("项目已恢复"); + } catch (err) { + void MessagePlugin.error((err as Error).message); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteMutation.mutateAsync(id); + void MessagePlugin.success("项目已永久删除"); + } catch (err) { + void MessagePlugin.error((err as Error).message); + } + }; + + const columns: Array> = [ + { colKey: "name", ellipsis: true, title: "项目名称", width: 160 }, + { colKey: "description", ellipsis: true, title: "项目描述" }, + { + align: "center", + cell: (params: PrimaryTableCellParams) => { + const { row } = params; + if (row.status === "archived") { + return ( + + 已归档 + + ); + } + return ( + + 进行中 + + ); + }, + colKey: "status", + title: "状态", + width: 100, + }, + { + align: "center", + cell: (params: PrimaryTableCellParams) => formatDatetime(params.row.createdAt), + colKey: "createdAt", + title: "创建时间", + width: 185, + }, + { + align: "center", + cell: (params: PrimaryTableCellParams) => formatDatetime(params.row.updatedAt), + colKey: "updatedAt", + title: "更新时间", + width: 185, + }, + { + cell: (params: PrimaryTableCellParams) => { + const { row } = params; + if (row.status === "active") { + return ( + + + void handleArchive(row.id)}> + + + + ); + } + return ( + + void handleRestore(row.id)}> + + + void handleDelete(row.id)}> + + + + ); + }, + colKey: "op", + fixed: "right", + title: "操作", + width: 180, + }, + ]; + + const isSubmitting = createMutation.isPending || updateMutation.isPending; + + return ( + +
+ + + { + setKeyword(""); + setSearchValue(""); + setPage(1); + }} + onKeydown={handleSearchKeydown} + placeholder="搜索项目名称或描述" + value={searchValue} + /> + + {tabValue === "active" && ( + + )} + +
+ + {isLoading ? ( + + ) : ( + { + const p = info as { current: number; pageSize: number }; + setPage(p.current); + setPageSize(p.pageSize); + }, + pageSize, + total: data?.total ?? 0, + }} + rowKey="id" + /> + )} + + setDialogVisible(false)} + onClose={() => setDialogVisible(false)} + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- handleDialogConfirm 是 async 但最终返回 void,lint 规则误报 + onConfirm={handleDialogConfirm} + onOpened={() => { + if (editingProject) { + void form?.setFieldsValue?.({ description: editingProject.description, name: editingProject.name }); + } else { + form?.reset?.(); + } + }} + visible={dialogVisible} + > +
+ + + + +