diff --git a/README.md b/README.md index 8ac1283..dfaa3bb 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,11 @@ npm install GITHUB_TOKEN="your-token" ``` -2. Initialize the local D1 database: +2. Apply migrations to the local D1 databases: ```bash - npm run init:db + npm run migrate:extensions-v2:local + npm run migrate:central-alerts:local ``` 3. (Optional) Store an update token in KV for `/versions/v1/update`: diff --git a/drizzle.central-alerts.config.ts b/drizzle.central-alerts.config.ts new file mode 100644 index 0000000..c27a05b --- /dev/null +++ b/drizzle.central-alerts.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "drizzle-kit"; + +// See drizzle.extensions.config.ts for why `out` matches wrangler's +// migrations_dir instead of using drizzle-kit's own D1 driver to apply. +export default defineConfig({ + dialect: "sqlite", + schema: "./src/services/central-alerts/v1/db/schema.ts", + out: "./src/services/central-alerts/v1/db/migrations" +}); diff --git a/drizzle.extensions.config.ts b/drizzle.extensions.config.ts new file mode 100644 index 0000000..a7078d2 --- /dev/null +++ b/drizzle.extensions.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "drizzle-kit"; + +// Schema source for the whole DB_EXTENSIONS database - shared by v1 (read +// only) and v2 (owns writes/migrations). Migrations are generated here but +// applied via `wrangler d1 migrations apply DB_EXTENSIONS` (see +// migrations_dir in wrangler.jsonc), not drizzle-kit's own D1 driver, so no +// Cloudflare credentials are needed just to generate a migration. +export default defineConfig({ + dialect: "sqlite", + schema: "./src/services/extensions/v2/db/schema.ts", + out: "./src/services/extensions/v2/db/migrations" +}); diff --git a/package-lock.json b/package-lock.json index 718b9a4..31f1cbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@octokit/request": "^10.0.2", "@scalar/hono-api-reference": "^0.11.11", "badge-maker": "^6.0.0", + "drizzle-orm": "^0.45.2", "hono": "^4.11.4", "semver": "^7.7.2", "zod": "^4.4.3" @@ -24,6 +25,7 @@ "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@vitest/coverage-istanbul": "4.1.10", + "drizzle-kit": "^0.31.10", "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-jest": "29.16.0", @@ -472,6 +474,13 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -506,27 +515,22 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", "cpu": [ "arm" ], @@ -537,13 +541,13 @@ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", "cpu": [ "arm64" ], @@ -554,13 +558,13 @@ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", "cpu": [ "x64" ], @@ -571,13 +575,13 @@ "android" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", "cpu": [ "arm64" ], @@ -588,13 +592,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", "cpu": [ "x64" ], @@ -605,13 +609,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", "cpu": [ "arm64" ], @@ -622,13 +626,13 @@ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", "cpu": [ "x64" ], @@ -639,13 +643,13 @@ "freebsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", "cpu": [ "arm" ], @@ -656,13 +660,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", "cpu": [ "arm64" ], @@ -673,13 +677,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", "cpu": [ "ia32" ], @@ -690,13 +694,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", "cpu": [ "loong64" ], @@ -707,13 +711,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", "cpu": [ "mips64el" ], @@ -724,13 +728,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", "cpu": [ "ppc64" ], @@ -741,13 +745,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", "cpu": [ "riscv64" ], @@ -758,13 +762,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", "cpu": [ "s390x" ], @@ -775,13 +779,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", "cpu": [ "x64" ], @@ -792,15 +796,15 @@ "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", @@ -809,13 +813,13 @@ "netbsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", "cpu": [ "x64" ], @@ -823,67 +827,67 @@ "license": "MIT", "optional": true, "os": [ - "netbsd" + "openbsd" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "sunos" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", "cpu": [ "x64" ], @@ -891,509 +895,950 @@ "license": "MIT", "optional": true, "os": [ - "sunos" + "win32" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/win32-arm64": { + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "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" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/win32-ia32": { + "node_modules/@esbuild/android-arm": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ - "ia32" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { + "node_modules/@esbuild/android-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@hono/zod-openapi": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.5.1.tgz", - "integrity": "sha512-ZaDdEIkn6PEGjIYHXJeyByg6yhvLzI+UXn968pWtahpwcQ6HMjQYXI3zNffTl0Wl9QZ79nUnGqiN1erEIu23fA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@asteasolutions/zod-to-openapi": "^8.5.0", - "@hono/zod-validator": "^0.9.0", - "openapi3-ts": "^4.5.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "hono": ">=4.10.0", - "zod": "^4.0.0" + "node": ">=18" } }, - "node_modules/@hono/zod-validator": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.9.0.tgz", - "integrity": "sha512-n0ZSXmCiHVIp4Y5wlOOyZCeTd/rsawA/qW1cipB8QOYKZ9N8Tk0nZUZCXho9cu374AN4JpDNKioNKBJ/W+LBug==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "hono": ">=4.11.2", - "zod": "^3.25.0 || ^4.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", - "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.1" + "node": ">=18" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", - "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.1" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", - "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", - "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "freebsd" + "netbsd" ], - "dependencies": { - "@img/sharp-wasm32": "0.35.2" - }, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", - "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "darwin" + "openbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", - "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", - "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", - "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", - "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", - "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", - "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ - "s390x" + "ia32" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", - "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", - "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@hono/zod-openapi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.5.1.tgz", + "integrity": "sha512-ZaDdEIkn6PEGjIYHXJeyByg6yhvLzI+UXn968pWtahpwcQ6HMjQYXI3zNffTl0Wl9QZ79nUnGqiN1erEIu23fA==", + "license": "MIT", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", + "@hono/zod-validator": "^0.9.0", + "openapi3-ts": "^4.5.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.10.0", + "zod": "^4.0.0" + } + }, + "node_modules/@hono/zod-validator": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.9.0.tgz", + "integrity": "sha512-n0ZSXmCiHVIp4Y5wlOOyZCeTd/rsawA/qW1cipB8QOYKZ9N8Tk0nZUZCXho9cu374AN4JpDNKioNKBJ/W+LBug==", + "license": "MIT", + "peerDependencies": { + "hono": ">=4.11.2", + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], "dev": true, "libc": [ "musl" @@ -1979,1100 +2424,1732 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-freebsd-x64": { + "node_modules/@rolldown/binding-openharmony-arm64": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "openharmony" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "node_modules/@rolldown/binding-wasm32-wasi": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ - "arm" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { + "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { + "node_modules/@rolldown/binding-win32-x64-msvc": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@scalar/client-side-rendering": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.3.4.tgz", + "integrity": "sha512-kb3B+FGjvAUr2DU0fe9dVKBLwot1TjOO+iHCTmY8r8FUJySmYKGQYCicRzzilOyTjvKspiZBCEr03PWaWW+1gw==", + "license": "MIT", + "dependencies": { + "@scalar/schemas": "0.7.4", + "@scalar/types": "0.16.4", + "@scalar/validation": "0.6.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.2.tgz", + "integrity": "sha512-hjyMpMZjTBZQhyByZmz5oUgRKUQJO5V5AOiJxsVEGbUmgA7sJRQeTrXLB+BEwzaKS5nm2opJeNyMBYLFNK4hiQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/hono-api-reference": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/@scalar/hono-api-reference/-/hono-api-reference-0.11.11.tgz", + "integrity": "sha512-yvJ2oqyG9MC4C55/Xvi/Jny5qBYDxDvoVleABhgNG24A93u9ar17qsdSppli94sQOUdX31Qw/yCTm89LHbBRiQ==", + "license": "MIT", + "dependencies": { + "@scalar/client-side-rendering": "0.3.4" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "hono": "^4.12.5" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.4.tgz", + "integrity": "sha512-Or31zxR+ceGGhkVU5XBO2Zv7oyGDtjsJOjM2Rr0WRZ1/tRsQc2/FsVv+9kPmCA7sqFL8BKWlNfTXcPITI7WRHw==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "@scalar/validation": "0.6.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.16.4", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.4.tgz", + "integrity": "sha512-fLf0ANAC3iQq0sIVdmH6aGM+pFSLyD8GfGBxSWcLpI0jE0iFAzX2A3p0qVZm7vqBT4TQnn0fSwg5U+h+FZ52BA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/validation": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.2.tgz", + "integrity": "sha512-Sc1TkcwGV6aVCO51AyKeaGiP8gpwAHxEtO5d3tZzPV+KsnlC/YokQxFxwBrbIXw73k9hmcExnJyGu3k5i6n6VA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, - "libc": [ - "musl" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/@scalar/client-side-rendering": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.3.4.tgz", - "integrity": "sha512-kb3B+FGjvAUr2DU0fe9dVKBLwot1TjOO+iHCTmY8r8FUJySmYKGQYCicRzzilOyTjvKspiZBCEr03PWaWW+1gw==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, "license": "MIT", "dependencies": { - "@scalar/schemas": "0.7.4", - "@scalar/types": "0.16.4", - "@scalar/validation": "0.6.2" + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": ">=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@scalar/helpers": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.2.tgz", - "integrity": "sha512-hjyMpMZjTBZQhyByZmz5oUgRKUQJO5V5AOiJxsVEGbUmgA7sJRQeTrXLB+BEwzaKS5nm2opJeNyMBYLFNK4hiQ==", - "license": "MIT", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=22" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@scalar/hono-api-reference": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/@scalar/hono-api-reference/-/hono-api-reference-0.11.11.tgz", - "integrity": "sha512-yvJ2oqyG9MC4C55/Xvi/Jny5qBYDxDvoVleABhgNG24A93u9ar17qsdSppli94sQOUdX31Qw/yCTm89LHbBRiQ==", + "node_modules/@vitest/coverage-istanbul": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.10.tgz", + "integrity": "sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==", + "dev": true, "license": "MIT", "dependencies": { - "@scalar/client-side-rendering": "0.3.4" + "@babel/core": "^7.29.0", + "@istanbuljs/schema": "^0.1.3", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/trace-mapping": "0.3.31", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=22" + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "hono": "^4.12.5" + "vitest": "4.1.10" } }, - "node_modules/@scalar/schemas": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.4.tgz", - "integrity": "sha512-Or31zxR+ceGGhkVU5XBO2Zv7oyGDtjsJOjM2Rr0WRZ1/tRsQc2/FsVv+9kPmCA7sqFL8BKWlNfTXcPITI7WRHw==", + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, "license": "MIT", "dependencies": { - "@scalar/helpers": "0.9.2", - "@scalar/validation": "0.6.2" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=22" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@scalar/types": { - "version": "0.16.4", - "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.4.tgz", - "integrity": "sha512-fLf0ANAC3iQq0sIVdmH6aGM+pFSLyD8GfGBxSWcLpI0jE0iFAzX2A3p0qVZm7vqBT4TQnn0fSwg5U+h+FZ52BA==", + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, "license": "MIT", "dependencies": { - "@scalar/helpers": "0.9.2", - "nanoid": "^5.1.6", - "type-fest": "^5.3.1", - "zod": "^4.3.5" + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "engines": { - "node": ">=22" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@scalar/validation": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.2.tgz", - "integrity": "sha512-Sc1TkcwGV6aVCO51AyKeaGiP8gpwAHxEtO5d3tZzPV+KsnlC/YokQxFxwBrbIXw73k9hmcExnJyGu3k5i6n6VA==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=20" + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, - "license": "MIT" + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, + "node_modules/anafanafo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anafanafo/-/anafanafo-2.0.0.tgz", + "integrity": "sha512-Nlfq7NC4AOkTJerWRIZcOAiMNtIDVIGWGvQ98O7Jl6Kr2Dk0dX5u4MqN778kSRTy5KRqchpLdF2RtLFEz9FVkQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "char-width-table-consumer": "^1.0.0" } }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" + } + }, + "node_modules/badge-maker": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/badge-maker/-/badge-maker-6.0.0.tgz", + "integrity": "sha512-+P5EsfmFWAR2cA9M0q20pa4OVOgnOXZkfRYUkQYHanECXCIg10ZnSBzelfBtEjt03b4Vac/yC1h55/wJPLmaOg==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "anafanafo": "2.0.0", + "css-color-converter": "^2.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "bin": { + "badge": "lib/badge-cli.js" }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "engines": { + "node": ">=20" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "debug": "^4.4.3" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", + "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "node_modules/binary-search": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/binary-search/-/binary-search-1.3.6.tgz", + "integrity": "sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==", + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", - "debug": "^4.4.3" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": "20 || >=22" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "bin": { + "browserslist": "cli.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", - "dev": true, + "node_modules/char-width-table-consumer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/char-width-table-consumer/-/char-width-table-consumer-1.0.0.tgz", + "integrity": "sha512-Fz4UD0LBpxPgL9i29CJ5O4KANwaMnX/OhhbxzvNa332h+9+nRKyeuLw4wA51lt/ex67+/AdsoBQJF3kgX2feYQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, + "binary-search": "^1.3.5" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==" + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://opencollective.com/express" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/express" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">= 8" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", - "dev": true, + "node_modules/css-color-converter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/css-color-converter/-/css-color-converter-2.0.0.tgz", + "integrity": "sha512-oLIG2soZz3wcC3aAl/7Us5RS8Hvvc6I8G8LniF/qfMmrm7fIKQ8RIDDRZeKyGL2SrWfNqYspuLShbnjBMVWm8g==", "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "color-convert": "^0.5.2", + "color-name": "^1.1.4", + "css-unit-converter": "^1.1.2" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "node_modules/css-unit-converter": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz", + "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "eslint-visitor-keys": "^5.0.0" + "ms": "^2.1.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=8" } }, - "node_modules/@vitest/coverage-istanbul": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.10.tgz", - "integrity": "sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==", + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", - "@istanbuljs/schema": "^0.1.3", - "@jridgewell/gen-mapping": "^0.3.13", - "@jridgewell/trace-mapping": "0.3.31", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" }, - "peerDependencies": { - "vitest": "4.1.10" + "bin": { + "drizzle-kit": "bin.cjs" } }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.4.0" + "node": ">=18" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/anafanafo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/anafanafo/-/anafanafo-2.0.0.tgz", - "integrity": "sha512-Nlfq7NC4AOkTJerWRIZcOAiMNtIDVIGWGvQ98O7Jl6Kr2Dk0dX5u4MqN778kSRTy5KRqchpLdF2RtLFEz9FVkQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "char-width-table-consumer": "^1.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/badge-maker": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/badge-maker/-/badge-maker-6.0.0.tgz", - "integrity": "sha512-+P5EsfmFWAR2cA9M0q20pa4OVOgnOXZkfRYUkQYHanECXCIg10ZnSBzelfBtEjt03b4Vac/yC1h55/wJPLmaOg==", - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "anafanafo": "2.0.0", - "css-color-converter": "^2.0.0" - }, - "bin": { - "badge": "lib/badge-cli.js" - }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", - "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/binary-search": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/binary-search/-/binary-search-1.3.6.tgz", - "integrity": "sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==", - "license": "CC0-1.0" - }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "20 || >=22" + "node": ">=18" } }, - "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=18" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" ], - "license": "CC-BY-4.0" + "engines": { + "node": ">=18" + } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { "node": ">=18" } }, - "node_modules/char-width-table-consumer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/char-width-table-consumer/-/char-width-table-consumer-1.0.0.tgz", - "integrity": "sha512-Fz4UD0LBpxPgL9i29CJ5O4KANwaMnX/OhhbxzvNa332h+9+nRKyeuLw4wA51lt/ex67+/AdsoBQJF3kgX2feYQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "binary-search": "^1.3.5" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", - "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==" - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/css-color-converter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/css-color-converter/-/css-color-converter-2.0.0.tgz", - "integrity": "sha512-oLIG2soZz3wcC3aAl/7Us5RS8Hvvc6I8G8LniF/qfMmrm7fIKQ8RIDDRZeKyGL2SrWfNqYspuLShbnjBMVWm8g==", + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^0.5.2", - "color-name": "^1.1.4", - "css-unit-converter": "^1.1.2" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/css-unit-converter": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz", - "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=6.0" + "node": ">=18" + }, + "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" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "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", + "sql.js": ">=1", + "sqlite3": ">=5" }, "peerDependenciesMeta": { - "supports-color": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { "optional": true } } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.396", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", @@ -3534,6 +4611,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4393,6 +5483,16 @@ "node": ">=6" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -4514,6 +5614,16 @@ "dev": true, "license": "ISC" }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4524,6 +5634,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index 9e89ba4..36a37b1 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,16 @@ "dev": "wrangler dev", "format": "prettier --write .", "format:check": "prettier --check .", - "init:db": "npx tsx src/services/central-alerts/v1/scripts/init-db.ts", + "db:generate:extensions": "drizzle-kit generate --config drizzle.extensions.config.ts", + "db:generate:central-alerts": "drizzle-kit generate --config drizzle.central-alerts.config.ts", "migrate:extensions-v2:local": "wrangler d1 migrations apply DB_EXTENSIONS --local", "migrate:extensions-v2:remote": "wrangler d1 migrations apply DB_EXTENSIONS --remote", "migrate:extensions-v2:list:local": "wrangler d1 migrations list DB_EXTENSIONS --local", "migrate:extensions-v2:list:remote": "wrangler d1 migrations list DB_EXTENSIONS --remote", + "migrate:central-alerts:local": "wrangler d1 migrations apply DB_CENTRAL_ALERTS --local", + "migrate:central-alerts:remote": "wrangler d1 migrations apply DB_CENTRAL_ALERTS --remote", + "migrate:central-alerts:list:local": "wrangler d1 migrations list DB_CENTRAL_ALERTS --local", + "migrate:central-alerts:list:remote": "wrangler d1 migrations list DB_CENTRAL_ALERTS --remote", "lint": "eslint", "lint:fix": "eslint --fix", "test": "vitest run", @@ -27,6 +32,7 @@ "@octokit/request": "^10.0.2", "@scalar/hono-api-reference": "^0.11.11", "badge-maker": "^6.0.0", + "drizzle-orm": "^0.45.2", "hono": "^4.11.4", "semver": "^7.7.2", "zod": "^4.4.3" @@ -39,6 +45,7 @@ "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@vitest/coverage-istanbul": "4.1.10", + "drizzle-kit": "^0.31.10", "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-jest": "29.16.0", diff --git a/src/lib/adapters/cloudflare/database.ts b/src/lib/adapters/cloudflare/database.ts deleted file mode 100644 index 8a3ec29..0000000 --- a/src/lib/adapters/cloudflare/database.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { IDatabase, IPreparedStatement } from "../../interfaces"; - -export class CloudflareD1Adapter implements IDatabase { - constructor(private d1: D1Database) {} - - prepare(query: string): IPreparedStatement { - return new CloudflareD1Statement(this.d1.prepare(query)); - } - - async batch(statements: IPreparedStatement[]): Promise { - const d1Statements = statements.map((stmt) => { - if (stmt instanceof CloudflareD1Statement) { - return stmt.getNativeStatement(); - } - throw new Error("Invalid statement type for D1 batch"); - }); - return this.d1.batch(d1Statements); - } -} - -class CloudflareD1Statement implements IPreparedStatement { - constructor(private stmt: D1PreparedStatement) {} - - bind(...params: unknown[]): IPreparedStatement { - this.stmt = this.stmt.bind(...params); - return this; - } - - async all(): Promise<{ - results?: T[]; - success: boolean; - error?: string; - }> { - const result = await this.stmt.all(); - return { - results: result.results, - success: result.success, - error: result.error ? String(result.error) : undefined - }; - } - - async first(): Promise { - return this.stmt.first(); - } - - async run(): Promise<{ - success: boolean; - error?: string; - meta?: { - changes?: number; - last_row_id?: number; - [key: string]: unknown; - }; - }> { - const result = await this.stmt.run(); - return { - success: result.success, - error: result.error ? String(result.error) : undefined, - meta: result.meta ? { ...result.meta } : undefined - }; - } - - getNativeStatement(): D1PreparedStatement { - return this.stmt; - } -} diff --git a/src/lib/adapters/cloudflare/index.ts b/src/lib/adapters/cloudflare/index.ts index 92113ab..8b8a444 100644 --- a/src/lib/adapters/cloudflare/index.ts +++ b/src/lib/adapters/cloudflare/index.ts @@ -1,16 +1,16 @@ import { IPlatformBindings } from "../../interfaces"; -import { CloudflareD1Adapter } from "./database"; import { CloudflareKVAdapter } from "./cache"; import { CloudflareEnvironmentAdapter } from "./environment"; +// Database access no longer goes through this platform-bindings +// abstraction - Drizzle wraps env.DB_EXTENSIONS/env.DB_CENTRAL_ALERTS +// directly (see src/lib/db.ts), since Hono's context already gives route +// handlers a typed `c.env` and Drizzle itself is the cross-driver +// abstraction now, making a hand-rolled IDatabase wrapper redundant. export function createCloudflareBindings( env: CloudflareBindings ): IPlatformBindings { return { - databases: { - DB_CENTRAL_ALERTS: new CloudflareD1Adapter(env.DB_CENTRAL_ALERTS), - DB_EXTENSIONS: new CloudflareD1Adapter(env.DB_EXTENSIONS) - }, caches: { CACHE_KV: new CloudflareKVAdapter(env.CACHE_KV), AUTH_KV: new CloudflareKVAdapter(env.AUTH_KV) @@ -21,6 +21,5 @@ export function createCloudflareBindings( }; } -export { CloudflareD1Adapter } from "./database"; export { CloudflareKVAdapter } from "./cache"; export { CloudflareEnvironmentAdapter } from "./environment"; diff --git a/src/lib/adapters/node/database.ts b/src/lib/adapters/node/database.ts deleted file mode 100644 index 4e7ecd0..0000000 --- a/src/lib/adapters/node/database.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { IDatabase, IPreparedStatement } from "../../interfaces"; -import { DatabaseSync, type SQLInputValue } from "node:sqlite"; - -export class SQLiteAdapter implements IDatabase { - constructor(private db: DatabaseSync) {} - - prepare(query: string): IPreparedStatement { - return new SQLiteStatement(this.db, query); - } - - async batch(statements: IPreparedStatement[]): Promise { - this.db.exec("BEGIN IMMEDIATE"); - const results = []; - - try { - for (const stmt of statements) { - if (stmt instanceof SQLiteStatement) { - const result = await stmt.run(); - - results.push(result); - } else { - throw new Error("Invalid statement type for SQLite batch"); - } - } - - this.db.exec("COMMIT"); - return results; - } catch (error) { - this.db.exec("ROLLBACK"); - throw error; - } - } -} - -type SQLiteParams = SQLInputValue[]; - -class SQLiteStatement implements IPreparedStatement { - private params: SQLiteParams = []; - private statement: ReturnType; - - constructor(db: DatabaseSync, query: string) { - this.statement = db.prepare(query); - } - - bind(...params: unknown[]): IPreparedStatement { - this.params = params as SQLiteParams; - return this; - } - - async all(): Promise<{ - results?: T[]; - success: boolean; - error?: string; - }> { - const results = this.statement.all(...this.params); - - return { - results: results as T[], - success: true - }; - } - - async first(): Promise { - const result = this.statement.get(...this.params); - return (result as T) ?? null; - } - - async run(): Promise<{ - success: boolean; - error?: string; - meta?: { - changes?: number; - last_row_id?: number; - [key: string]: unknown; - }; - }> { - const result = this.statement.run(...this.params); - - return { - success: true, - meta: { - changes: Number(result.changes), - last_row_id: Number(result.lastInsertRowid) - } - }; - } -} - -export function createInMemoryDatabase(): DatabaseSync { - return new DatabaseSync(":memory:"); -} - -export function createFileDatabase(filename: string): DatabaseSync { - return new DatabaseSync(filename); -} - -export function createDefaultAdapter(): SQLiteAdapter { - const db = createInMemoryDatabase(); - return new SQLiteAdapter(db); -} diff --git a/src/lib/adapters/node/index.ts b/src/lib/adapters/node/index.ts index 99ea53f..9039f8c 100644 --- a/src/lib/adapters/node/index.ts +++ b/src/lib/adapters/node/index.ts @@ -1,8 +1,11 @@ import { IPlatformBindings } from "../../interfaces"; import { createMemoryCache, createFileCache } from "./cache"; import { NodeEnvironmentAdapter } from "./environment"; -import { createDefaultAdapter } from "./database"; +// No database entry here - Drizzle only wraps the real D1 bindings (see +// src/lib/db.ts); the Node path exists solely to test the cache/environment +// adapters outside Workers, so it never had a real production consumer for +// a database binding (only src/app/index.ts's createCloudflareBindings does). export function createNodeBindings(cacheDbPath?: string): IPlatformBindings { const cacheKv = cacheDbPath ? createFileCache(`${normalizePath(cacheDbPath)}.kv`) @@ -12,9 +15,6 @@ export function createNodeBindings(cacheDbPath?: string): IPlatformBindings { : createMemoryCache(); return { - databases: { - DB_EXTENSIONS: createDefaultAdapter() - }, caches: { CACHE_KV: cacheKv, AUTH_KV: authKv @@ -37,9 +37,3 @@ export { createFileCache } from "./cache"; export { NodeEnvironmentAdapter } from "./environment"; -export { - SQLiteAdapter, - createInMemoryDatabase, - createFileDatabase, - createDefaultAdapter -} from "./database"; diff --git a/src/lib/context.ts b/src/lib/context.ts index 8420b40..1bed40f 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -1,8 +1,7 @@ import { Context } from "hono"; -import { IDatabase, ICache, IPlatformBindings } from "./interfaces"; +import { ICache, IPlatformBindings } from "./interfaces"; export interface PlatformContext { - getDatabase(name: string): IDatabase; getCache(name: string): ICache; getEnv(key: string): string | undefined; readonly raw: Context; @@ -13,14 +12,6 @@ export function createPlatformContext( bindings: IPlatformBindings ): PlatformContext { return { - getDatabase(name: string): IDatabase { - const db = bindings.databases[name]; - if (!db) { - throw new Error(`Database binding '${name}' not found`); - } - return db; - }, - getCache(name: string): ICache { const cache = bindings.caches[name]; if (!cache) { diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..9e972b1 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,18 @@ +import { drizzle } from "drizzle-orm/d1"; +import * as extensionsSchema from "../services/extensions/v2/db/schema"; +import * as centralAlertsSchema from "../services/central-alerts/v1/db/schema"; + +export type ExtensionsDb = ReturnType>; +export type CentralAlertsDb = ReturnType< + typeof drizzle +>; + +// DB_EXTENSIONS is shared by v1 (read-only) and v2 (owns writes/migrations, +// see src/services/extensions/v2/db/schema.ts for the full table set). +export function getExtensionsDb(d1: D1Database): ExtensionsDb { + return drizzle(d1, { schema: extensionsSchema }); +} + +export function getCentralAlertsDb(d1: D1Database): CentralAlertsDb { + return drizzle(d1, { schema: centralAlertsSchema }); +} diff --git a/src/lib/interfaces.ts b/src/lib/interfaces.ts index a0f491c..233280b 100644 --- a/src/lib/interfaces.ts +++ b/src/lib/interfaces.ts @@ -8,30 +8,6 @@ export interface DatabaseResult { error: DatabaseError | null; } -export interface IDatabase { - prepare(query: string): IPreparedStatement; - batch?(statements: IPreparedStatement[]): Promise; -} - -export interface IPreparedStatement { - bind(...params: unknown[]): IPreparedStatement; - all(): Promise<{ - results?: T[]; - success: boolean; - error?: string; - }>; - first(): Promise; - run(): Promise<{ - success: boolean; - error?: string; - meta?: { - changes?: number; - last_row_id?: number; - [key: string]: unknown; - }; - }>; -} - export interface CacheOptions { expirationTtl?: number; expiration?: number; @@ -49,7 +25,6 @@ export interface IEnvironment { } export interface IPlatformBindings { - databases: Record; caches: Record; environment: IEnvironment; } diff --git a/src/services/central-alerts/v1/database.ts b/src/services/central-alerts/v1/database.ts index e0473a9..d2f3290 100644 --- a/src/services/central-alerts/v1/database.ts +++ b/src/services/central-alerts/v1/database.ts @@ -1,33 +1,19 @@ +import { desc } from "drizzle-orm"; import { CentralAlert } from "./interfaces"; -import { DatabaseResult, IDatabase } from "../../../lib/interfaces"; +import { DatabaseResult } from "../../../lib/interfaces"; +import { CentralAlertsDb } from "../../../lib/db"; +import { centralAlerts } from "./db/schema"; export class CentralAlertsDatabase { - private db: IDatabase; - - constructor(db: IDatabase) { - this.db = db; - } + constructor(private db: CentralAlertsDb) {} async getAllAlerts(): Promise> { - const query = ` - SELECT - id, - title, - message, - type, - dismissible, - min_fossbilling_version, - max_fossbilling_version, - include_preview_branch, - buttons, - "datetime" - FROM central_alerts - ORDER BY "datetime" DESC - `; - - let result; + let rows; try { - result = await this.db.prepare(query).all>(); + rows = await this.db + .select() + .from(centralAlerts) + .orderBy(desc(centralAlerts.datetime)); } catch (error) { return { data: null, @@ -38,31 +24,24 @@ export class CentralAlertsDatabase { }; } - if (!result.success) { - return { - data: null, - error: { - message: result.error || "Database query failed", - code: "DATABASE_ERROR" - } - }; - } - - const alerts = (result.results ?? []).map((alert) => { - const alertData = alert as Record; - return { - ...alertData, - dismissible: Boolean(alertData.dismissible), - include_preview_branch: Boolean(alertData.include_preview_branch), - buttons: parseButtons(alertData.buttons) - }; - }) as CentralAlert[]; + const alerts: CentralAlert[] = rows.map((row) => ({ + id: row.id, + title: row.title, + message: row.message, + type: row.type as CentralAlert["type"], + dismissible: row.dismissible, + min_fossbilling_version: row.minFossbillingVersion, + max_fossbilling_version: row.maxFossbillingVersion, + include_preview_branch: row.includePreviewBranch, + buttons: parseButtons(row.buttons), + datetime: row.datetime + })); return { data: alerts, error: null }; } } -function parseButtons(value: unknown): CentralAlert["buttons"] { +function parseButtons(value: string | null): CentralAlert["buttons"] { if (typeof value === "string") { try { const parsed = JSON.parse(value); @@ -72,9 +51,5 @@ function parseButtons(value: unknown): CentralAlert["buttons"] { } } - if (Array.isArray(value)) { - return value as CentralAlert["buttons"]; - } - return []; } diff --git a/src/services/central-alerts/v1/db/init.sql b/src/services/central-alerts/v1/db/init.sql deleted file mode 100644 index 333a4fb..0000000 --- a/src/services/central-alerts/v1/db/init.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Initialize Central Alerts Database --- Run this once to set up your D1 database - -CREATE TABLE IF NOT EXISTS central_alerts ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - message TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('success', 'info', 'warning', 'danger')), - dismissible BOOLEAN NOT NULL DEFAULT false, - min_fossbilling_version TEXT NOT NULL, - max_fossbilling_version TEXT NOT NULL, - include_preview_branch BOOLEAN NOT NULL DEFAULT false, - buttons JSON DEFAULT '[]', - datetime TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_central_alerts_type ON central_alerts(type); -CREATE INDEX IF NOT EXISTS idx_central_alerts_version_range ON central_alerts(min_fossbilling_version, max_fossbilling_version); -CREATE INDEX IF NOT EXISTS idx_central_alerts_datetime ON central_alerts(datetime); - --- Insert sample data (delete these if you want to start empty) -INSERT OR IGNORE INTO central_alerts (id, title, message, type, dismissible, min_fossbilling_version, max_fossbilling_version, include_preview_branch, datetime, buttons) VALUES -('1', 'This version of FOSSBilling is insecure', 'FOSSBilling versions older than 0.5.3 are vulnerable to SQL injection with a critical (9.8) severity. Please update now to protect you and your customers.', 'danger', false, '0.0.0', '0.5.2', false, '2023-06-30T21:43:03+00:00', -'[{"text": "CVE Details", "link": "https://nvd.nist.gov/vuln/detail/CVE-2023-3490", "type": "info"}, {"text": "Original vulnerability report", "link": "https://huntr.dev/bounties/4e60ebc1-e00f-48cb-b011-3cefce688ecd/", "type": "info"}]'); \ No newline at end of file diff --git a/src/services/central-alerts/v1/db/migrations/0000_create_central_alerts.sql b/src/services/central-alerts/v1/db/migrations/0000_create_central_alerts.sql new file mode 100644 index 0000000..00f331c --- /dev/null +++ b/src/services/central-alerts/v1/db/migrations/0000_create_central_alerts.sql @@ -0,0 +1,24 @@ +-- IF NOT EXISTS added by hand after drizzle-kit generate: this is the +-- baseline migration for a database that, in local/remote environments +-- that already ran the old init.sql bootstrap, has this table already. +-- Matches the idempotent CREATE TABLE/INDEX convention used by every +-- extensions-v2 migration in the sibling database for the same reason. +CREATE TABLE IF NOT EXISTS `central_alerts` ( + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `message` text NOT NULL, + `type` text NOT NULL, + `dismissible` integer DEFAULT false NOT NULL, + `min_fossbilling_version` text NOT NULL, + `max_fossbilling_version` text NOT NULL, + `include_preview_branch` integer DEFAULT false NOT NULL, + `buttons` text DEFAULT '[]', + `datetime` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP, + `updated_at` text DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "central_alerts_type_check" CHECK("central_alerts"."type" IN ('success', 'info', 'warning', 'danger')) +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_central_alerts_type` ON `central_alerts` (`type`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_central_alerts_version_range` ON `central_alerts` (`min_fossbilling_version`,`max_fossbilling_version`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_central_alerts_datetime` ON `central_alerts` (`datetime`); \ No newline at end of file diff --git a/src/services/central-alerts/v1/db/migrations/0001_seed_initial_alert.sql b/src/services/central-alerts/v1/db/migrations/0001_seed_initial_alert.sql new file mode 100644 index 0000000..f7cddf4 --- /dev/null +++ b/src/services/central-alerts/v1/db/migrations/0001_seed_initial_alert.sql @@ -0,0 +1,5 @@ +-- Data seed, split out from the schema migration (0000) so future schema +-- diffs never touch it. Ported verbatim from the old db/init.sql bootstrap. +INSERT OR IGNORE INTO central_alerts (id, title, message, type, dismissible, min_fossbilling_version, max_fossbilling_version, include_preview_branch, datetime, buttons) VALUES +('1', 'This version of FOSSBilling is insecure', 'FOSSBilling versions older than 0.5.3 are vulnerable to SQL injection with a critical (9.8) severity. Please update now to protect you and your customers.', 'danger', false, '0.0.0', '0.5.2', false, '2023-06-30T21:43:03+00:00', +'[{"text": "CVE Details", "link": "https://nvd.nist.gov/vuln/detail/CVE-2023-3490", "type": "info"}, {"text": "Original vulnerability report", "link": "https://huntr.dev/bounties/4e60ebc1-e00f-48cb-b011-3cefce688ecd/", "type": "info"}]'); diff --git a/src/services/central-alerts/v1/db/migrations/meta/0000_snapshot.json b/src/services/central-alerts/v1/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..68525e6 --- /dev/null +++ b/src/services/central-alerts/v1/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,138 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4b97c5a2-21b4-4949-a981-f94e4dc10964", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "central_alerts": { + "name": "central_alerts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissible": { + "name": "dismissible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "min_fossbilling_version": { + "name": "min_fossbilling_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_fossbilling_version": { + "name": "max_fossbilling_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "include_preview_branch": { + "name": "include_preview_branch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "buttons": { + "name": "buttons", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "datetime": { + "name": "datetime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_central_alerts_type": { + "name": "idx_central_alerts_type", + "columns": ["type"], + "isUnique": false + }, + "idx_central_alerts_version_range": { + "name": "idx_central_alerts_version_range", + "columns": ["min_fossbilling_version", "max_fossbilling_version"], + "isUnique": false + }, + "idx_central_alerts_datetime": { + "name": "idx_central_alerts_datetime", + "columns": ["datetime"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "central_alerts_type_check": { + "name": "central_alerts_type_check", + "value": "\"central_alerts\".\"type\" IN ('success', 'info', 'warning', 'danger')" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/src/services/central-alerts/v1/db/migrations/meta/0001_snapshot.json b/src/services/central-alerts/v1/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..674ef79 --- /dev/null +++ b/src/services/central-alerts/v1/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,138 @@ +{ + "id": "104d2463-6db4-4905-b41f-0ce2ffcbf23d", + "prevId": "4b97c5a2-21b4-4949-a981-f94e4dc10964", + "version": "6", + "dialect": "sqlite", + "tables": { + "central_alerts": { + "name": "central_alerts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissible": { + "name": "dismissible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "min_fossbilling_version": { + "name": "min_fossbilling_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_fossbilling_version": { + "name": "max_fossbilling_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "include_preview_branch": { + "name": "include_preview_branch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "buttons": { + "name": "buttons", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "datetime": { + "name": "datetime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_central_alerts_type": { + "name": "idx_central_alerts_type", + "columns": ["type"], + "isUnique": false + }, + "idx_central_alerts_version_range": { + "name": "idx_central_alerts_version_range", + "columns": ["min_fossbilling_version", "max_fossbilling_version"], + "isUnique": false + }, + "idx_central_alerts_datetime": { + "name": "idx_central_alerts_datetime", + "columns": ["datetime"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "central_alerts_type_check": { + "name": "central_alerts_type_check", + "value": "\"central_alerts\".\"type\" IN ('success', 'info', 'warning', 'danger')" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/src/services/central-alerts/v1/db/migrations/meta/_journal.json b/src/services/central-alerts/v1/db/migrations/meta/_journal.json new file mode 100644 index 0000000..5bd96f2 --- /dev/null +++ b/src/services/central-alerts/v1/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1785258777064, + "tag": "0000_create_central_alerts", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785258811098, + "tag": "0001_seed_initial_alert", + "breakpoints": true + } + ] +} diff --git a/src/services/central-alerts/v1/db/schema.ts b/src/services/central-alerts/v1/db/schema.ts new file mode 100644 index 0000000..84fe6d9 --- /dev/null +++ b/src/services/central-alerts/v1/db/schema.ts @@ -0,0 +1,56 @@ +import { sql } from "drizzle-orm"; +import { + sqliteTable, + text, + integer, + index, + check +} from "drizzle-orm/sqlite-core"; + +// Historically bootstrapped via `wrangler d1 execute --file` against a +// hand-written init.sql, not wrangler's migrations mechanism. This schema +// is the baseline for adopting drizzle-kit-managed migrations for +// DB_CENTRAL_ALERTS - see db/migrations/0000_create_central_alerts.sql. +// +// dismissible/include_preview_branch were declared BOOLEAN in the original +// DDL (SQLite has no native boolean type - BOOLEAN gets NUMERIC affinity, +// same underlying storage as INTEGER). Modeled here as +// integer(..., { mode: "boolean" }) for real type safety; the generated +// migration's declared type name will read "integer" rather than +// "boolean" - a cosmetic, affinity-equivalent difference confirmed safe +// during baseline verification. +export const centralAlerts = sqliteTable( + "central_alerts", + { + id: text("id").primaryKey(), + title: text("title").notNull(), + message: text("message").notNull(), + type: text("type").notNull(), + dismissible: integer("dismissible", { mode: "boolean" }) + .notNull() + .default(false), + minFossbillingVersion: text("min_fossbilling_version").notNull(), + maxFossbillingVersion: text("max_fossbilling_version").notNull(), + includePreviewBranch: integer("include_preview_branch", { + mode: "boolean" + }) + .notNull() + .default(false), + buttons: text("buttons").default("[]"), + datetime: text("datetime").notNull(), + createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`) + }, + (table) => [ + index("idx_central_alerts_type").on(table.type), + index("idx_central_alerts_version_range").on( + table.minFossbillingVersion, + table.maxFossbillingVersion + ), + index("idx_central_alerts_datetime").on(table.datetime), + check( + "central_alerts_type_check", + sql`${table.type} IN ('success', 'info', 'warning', 'danger')` + ) + ] +); diff --git a/src/services/central-alerts/v1/index.ts b/src/services/central-alerts/v1/index.ts index 1b2f687..663aabd 100644 --- a/src/services/central-alerts/v1/index.ts +++ b/src/services/central-alerts/v1/index.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { trimTrailingSlash } from "hono/trailing-slash"; import { CentralAlertsDatabase } from "./database"; -import { getPlatform } from "../../../lib/middleware"; +import { getCentralAlertsDb } from "../../../lib/db"; import { logError } from "../../../lib/logger"; const centralAlertsV1 = new Hono<{ Bindings: CloudflareBindings }>(); @@ -10,9 +10,8 @@ const centralAlertsV1 = new Hono<{ Bindings: CloudflareBindings }>(); centralAlertsV1.use("/*", cors({ origin: "*" }), trimTrailingSlash()); centralAlertsV1.get("/list", async (c) => { - const platform = getPlatform(c); const db = new CentralAlertsDatabase( - platform.getDatabase("DB_CENTRAL_ALERTS") + getCentralAlertsDb(c.env.DB_CENTRAL_ALERTS) ); const { data, error } = await db.getAllAlerts(); diff --git a/src/services/central-alerts/v1/scripts/init-db.ts b/src/services/central-alerts/v1/scripts/init-db.ts deleted file mode 100644 index b7c45c5..0000000 --- a/src/services/central-alerts/v1/scripts/init-db.ts +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from "child_process"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const WRANGLER_D1_COMMAND = [ - "wrangler", - "d1", - "execute", - "api_central-alerts", - "--local" -]; - -function executeWranglerCommand(args: string[]): void { - const command = [...WRANGLER_D1_COMMAND, ...args]; - const result = spawnSync("npx", command, { - encoding: "utf8", - stdio: "pipe" - }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - throw new Error( - [ - `Wrangler command failed: npx ${command.join(" ")}`, - result.stderr.trim() ? `stderr: ${result.stderr.trim()}` : "", - result.stdout.trim() ? `stdout: ${result.stdout.trim()}` : "" - ] - .filter(Boolean) - .join("\n") - ); - } -} - -function initializeDatabase(): void { - console.log("Initializing Central Alerts Database..."); - - const initSQLPath = join(__dirname, "..", "db", "init.sql"); - try { - executeWranglerCommand(["--file", initSQLPath]); - } catch (error) { - console.error(`Failed SQL file: ${initSQLPath}`, error); - throw error; - } - - console.log("Database initialization completed successfully!"); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - try { - initializeDatabase(); - } catch (error) { - console.error( - `Database initialization failed: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - } -} - -export { initializeDatabase }; diff --git a/src/services/extensions/v1/database.ts b/src/services/extensions/v1/database.ts index f9fefac..9946a58 100644 --- a/src/services/extensions/v1/database.ts +++ b/src/services/extensions/v1/database.ts @@ -1,4 +1,7 @@ -import { DatabaseResult, IDatabase } from "../../../lib/interfaces"; +import { eq, sql } from "drizzle-orm"; +import { ExtensionsDb } from "../../../lib/db"; +import { extensions, developers } from "../v2/db/schema"; +import { DatabaseResult } from "../../../lib/interfaces"; import { Extension, Release, @@ -7,33 +10,59 @@ import { sortReleasesDescending } from "./interfaces"; -const SELECT_EXTENSIONS = ` - SELECT e.id, e.type, e.author_id, - a.type AS author_type, a.name AS author_name, a.url AS author_url, - e.name, e.description, e.releases, e.website, e.license, - e.icon_url, e.readme, e.source, e.version, e.download_url - FROM extensions e - LEFT JOIN developers a ON e.author_id = a.id -`; +const EXTENSION_COLUMNS = { + id: extensions.id, + type: extensions.type, + authorId: extensions.authorId, + authorType: developers.type, + authorName: developers.name, + authorUrl: developers.url, + name: extensions.name, + description: extensions.description, + releases: extensions.releases, + website: extensions.website, + license: extensions.license, + iconUrl: extensions.iconUrl, + readme: extensions.readme, + source: extensions.source, + version: extensions.version, + downloadUrl: extensions.downloadUrl +}; -export class ExtensionsDatabase { - private db: IDatabase; +// A LEFT JOIN can produce no matching developers row, so every joined +// (author*) column is nullable regardless of developers' own NOT NULL +// constraints - only extensions' own columns (besides iconUrl) are +// guaranteed non-null. +interface ExtensionRow { + id: string; + type: string; + authorId: string; + authorType: string | null; + authorName: string | null; + authorUrl: string | null; + name: string; + description: string; + releases: string; + website: string; + license: string; + iconUrl: string | null; + readme: string; + source: string; + version: string; + downloadUrl: string; +} - constructor(db: IDatabase) { - this.db = db; - } +export class ExtensionsDatabase { + constructor(private db: ExtensionsDb) {} async getAllExtensions(type?: string): Promise> { - const query = type - ? `${SELECT_EXTENSIONS} WHERE e.type = ?` - : SELECT_EXTENSIONS; - - let result; + let rows: ExtensionRow[]; try { - const stmt = this.db.prepare(query); - result = type - ? await stmt.bind(type).all>() - : await stmt.all>(); + const query = this.db + .select(EXTENSION_COLUMNS) + .from(extensions) + .leftJoin(developers, eq(extensions.authorId, developers.id)); + rows = type ? await query.where(eq(extensions.type, type)) : await query; } catch (error) { return { data: null, @@ -44,29 +73,17 @@ export class ExtensionsDatabase { }; } - if (!result.success) { - return { - data: null, - error: { - message: result.error || "Database query failed", - code: "DATABASE_ERROR" - } - }; - } - - const extensions = (result.results ?? []).map(parseExtensionRow); - return { data: extensions, error: null }; + return { data: rows.map(parseExtensionRow), error: null }; } async getExtensionById(id: string): Promise> { - const query = `${SELECT_EXTENSIONS} WHERE LOWER(e.id) = LOWER(?)`; - - let result; + let rows: ExtensionRow[]; try { - result = await this.db - .prepare(query) - .bind(id) - .first>(); + rows = await this.db + .select(EXTENSION_COLUMNS) + .from(extensions) + .leftJoin(developers, eq(extensions.authorId, developers.id)) + .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); } catch (error) { return { data: null, @@ -77,7 +94,8 @@ export class ExtensionsDatabase { }; } - if (!result) { + const row = rows[0]; + if (!row) { return { data: null, error: { @@ -87,7 +105,7 @@ export class ExtensionsDatabase { }; } - return { data: parseExtensionRow(result), error: null }; + return { data: parseExtensionRow(row), error: null }; } } @@ -102,26 +120,26 @@ function parseJSON(value: unknown, fallback: T): T { return value !== undefined && value !== null ? (value as T) : fallback; } -function parseExtensionRow(row: Record): Extension { +function parseExtensionRow(row: ExtensionRow): Extension { const releases = parseJSON(row.releases, []); return { - id: row.id as string, + id: row.id, type: row.type as Extension["type"], - name: row.name as string, - description: row.description as string, + name: row.name, + description: row.description, author: { - type: (row.author_type as "organization" | "user") ?? "user", - name: (row.author_name as string) ?? "", - id: (row.author_id as Lowercase) ?? ("" as Lowercase), - URL: typeof row.author_url === "string" ? row.author_url : undefined + type: (row.authorType as "organization" | "user") ?? "user", + name: row.authorName ?? "", + id: (row.authorId as Lowercase) ?? ("" as Lowercase), + URL: row.authorUrl ?? undefined } as Author, releases: sortReleasesDescending(releases), - website: row.website as string, + website: row.website, license: parseJSON(row.license, { name: "" }), - icon_url: typeof row.icon_url === "string" ? row.icon_url : undefined, - readme: row.readme as string, + icon_url: row.iconUrl ?? undefined, + readme: row.readme, source: parseJSON(row.source, { type: "custom", repo: "" }), - version: row.version as string, - download_url: row.download_url as string + version: row.version, + download_url: row.downloadUrl }; } diff --git a/src/services/extensions/v1/index.ts b/src/services/extensions/v1/index.ts index 817160f..188644a 100644 --- a/src/services/extensions/v1/index.ts +++ b/src/services/extensions/v1/index.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { trimTrailingSlash } from "hono/trailing-slash"; import { makeBadge } from "badge-maker"; -import { getPlatform } from "../../../lib/middleware"; +import { getExtensionsDb } from "../../../lib/db"; import { ExtensionsDatabase } from "./database"; import { getLatestRelease, sortReleasesDescending } from "./interfaces"; @@ -12,8 +12,7 @@ extensionsV1.use("/*", cors({ origin: "*" })); extensionsV1.use("/*", trimTrailingSlash()); extensionsV1.get("/list", async (c) => { - const platform = getPlatform(c); - const db = new ExtensionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const type = c.req.query("type"); const { data, error } = await db.getAllExtensions(type); @@ -28,8 +27,7 @@ extensionsV1.get("/:id/badges/:type", async (c) => { const id = c.req.param("id"); const badgeType = c.req.param("type"); - const platform = getPlatform(c); - const db = new ExtensionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data: extension, error } = await db.getExtensionById(id); if (error || !extension) { @@ -78,8 +76,7 @@ extensionsV1.get("/:id/badges/:type", async (c) => { extensionsV1.get("/:id/version", async (c) => { const id = c.req.param("id"); - const platform = getPlatform(c); - const db = new ExtensionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data: extension, error } = await db.getExtensionById(id); if (error || !extension) { @@ -101,8 +98,7 @@ extensionsV1.get("/:id/version", async (c) => { extensionsV1.get("/:id", async (c) => { const id = c.req.param("id"); - const platform = getPlatform(c); - const db = new ExtensionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data: extension, error } = await db.getExtensionById(id); if (error || !extension) { diff --git a/src/services/extensions/v2/d1-batch.ts b/src/services/extensions/v2/d1-batch.ts new file mode 100644 index 0000000..632d7d6 --- /dev/null +++ b/src/services/extensions/v2/d1-batch.ts @@ -0,0 +1,16 @@ +// drizzle-orm 0.45.2's D1 batch() throws "Cannot read properties of +// undefined (reading 'bind')" for any db.run(sql`...`) item that has bound +// params (confirmed via an isolated repro against real D1 - its +// prepared-query wrapper for raw sql lacks the .stmt property batch() +// unconditionally reads). Statements that need changes()-gating or +// correlated subqueries the query builder can't express, and therefore +// have to be batched together as raw sql, go through the underlying D1 +// client directly instead (Drizzle's documented $client escape hatch) - +// this converts a hand-written {sql, params} pair, or a query builder's +// own .toSQL() output, into a real D1PreparedStatement for that. +export function toD1Statement( + client: D1Database, + query: { sql: string; params: readonly unknown[] } +): D1PreparedStatement { + return client.prepare(query.sql).bind(...query.params); +} diff --git a/src/services/extensions/v2/db/external-tables.ts b/src/services/extensions/v2/db/external-tables.ts new file mode 100644 index 0000000..7b2bf37 --- /dev/null +++ b/src/services/extensions/v2/db/external-tables.ts @@ -0,0 +1,16 @@ +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; + +// Owned by the sibling FOSSBilling/extensions repo (src/lib/db/users.sql +// there), NOT this repo, but lives in the same DB_EXTENSIONS database. +// Deliberately kept out of schema.ts (drizzle-kit's scan target - see +// drizzle.extensions.config.ts) so drizzle-kit never thinks it owns and +// should generate migrations for a table this repo doesn't manage. This is +// a second, purely-for-reading definition of the same physical table +// schema.ts's `users` (id only) already declares for FK-reference purposes +// - if the sibling repo's columns change, update both. +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + isModerator: integer("is_moderator"), + githubLogin: text("github_login"), + githubOrgs: text("github_orgs") +}); diff --git a/src/services/extensions/v2/db/migrations/0014_drizzle_baseline.sql b/src/services/extensions/v2/db/migrations/0014_drizzle_baseline.sql new file mode 100644 index 0000000..39c823f --- /dev/null +++ b/src/services/extensions/v2/db/migrations/0014_drizzle_baseline.sql @@ -0,0 +1,35 @@ +-- This is a drizzle-kit baseline marker, not a real schema change. +-- +-- drizzle-kit generate has no knowledge of migrations 0001-0013 (they +-- predate drizzle-kit adoption in this repo and were hand-written, applied +-- via `wrangler d1 migrations apply`, with no drizzle journal of their +-- own). The first `drizzle-kit generate` run against schema.ts therefore +-- produced a full "CREATE TABLE ..." migration recreating every table +-- those 13 migrations already built - every local/remote DB_EXTENSIONS +-- database already has these tables, so running that SQL for real here +-- would fail with "table already exists". +-- +-- This file's body was verified (see the schema-diff script referenced +-- below) to be structurally equivalent to the cumulative effect of +-- 0001-0013, then intentionally emptied to a no-op: applying it via +-- `wrangler d1 migrations apply` just records it as applied in Wrangler's +-- `d1_migrations` bookkeeping table without touching the schema, bringing +-- every environment (which already has 0001-0013 applied for real) in +-- sync with drizzle-kit's own journal/snapshot state +-- (meta/_journal.json idx 14, meta/0014_snapshot.json). Every migration +-- after this one is a genuine, drizzle-kit-generated schema diff. +-- +-- One intentional, verified divergence from the original migrations: the +-- generated snapshot models extension_submissions.created_at/reviewed_at +-- as TEXT (schema.ts's text()) rather than the original migration's +-- DATETIME declared type. Both are NUMERIC/TEXT-affinity-compatible and +-- CURRENT_TIMESTAMP inserts an identical TEXT value either way - this +-- just makes the declared type consistent with every other timestamp +-- column in the schema, which already use TEXT. +-- +-- A genuine (harmless) statement is required below - a comment-only file +-- parses as one "statement" that's pure comment text, which some D1 +-- tooling (e.g. @cloudflare/vitest-pool-workers' applyD1Migrations, used +-- in tests) rejects with "SQL code did not contain a statement" when it +-- tries to prepare() that string directly. +SELECT 1; diff --git a/src/services/extensions/v2/db/migrations/meta/0014_snapshot.json b/src/services/extensions/v2/db/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..5c3399c --- /dev/null +++ b/src/services/extensions/v2/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,838 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1f723cdd-d2fc-4e13-9f71-a02e3938ccd6", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "developer_claims": { + "name": "developer_claims", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimant_id": { + "name": "claimant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_org_verified": { + "name": "github_org_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_verification_note": { + "name": "github_verification_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_developer_claims_developer": { + "name": "idx_developer_claims_developer", + "columns": ["developer_id"], + "isUnique": false + }, + "idx_developer_claims_claimant": { + "name": "idx_developer_claims_claimant", + "columns": ["claimant_id"], + "isUnique": false + }, + "idx_developer_claims_pending_unique": { + "name": "idx_developer_claims_pending_unique", + "columns": ["developer_id", "claimant_id"], + "isUnique": true, + "where": "\"developer_claims\".\"status\" = 'pending'" + }, + "idx_developer_claims_pending_queue": { + "name": "idx_developer_claims_pending_queue", + "columns": ["created_at"], + "isUnique": false, + "where": "\"developer_claims\".\"status\" = 'pending'" + } + }, + "foreignKeys": { + "developer_claims_developer_id_developers_id_fk": { + "name": "developer_claims_developer_id_developers_id_fk", + "tableFrom": "developer_claims", + "tableTo": "developers", + "columnsFrom": ["developer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_claims_claimant_id_users_id_fk": { + "name": "developer_claims_claimant_id_users_id_fk", + "tableFrom": "developer_claims", + "tableTo": "users", + "columnsFrom": ["claimant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_claims_reviewer_id_users_id_fk": { + "name": "developer_claims_reviewer_id_users_id_fk", + "tableFrom": "developer_claims", + "tableTo": "users", + "columnsFrom": ["reviewer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "developer_claims_status_check": { + "name": "developer_claims_status_check", + "value": "\"developer_claims\".\"status\" IN ('pending', 'approved', 'rejected')" + }, + "developer_claims_github_org_verified_check": { + "name": "developer_claims_github_org_verified_check", + "value": "\"developer_claims\".\"github_org_verified\" IN (0, 1)" + } + } + }, + "developer_history": { + "name": "developer_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_developer_history_developer_changed_at": { + "name": "idx_developer_history_developer_changed_at", + "columns": ["developer_id", "changed_at"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_history_changed_by_users_id_fk": { + "name": "developer_history_changed_by_users_id_fk", + "tableFrom": "developer_history", + "tableTo": "users", + "columnsFrom": ["changed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "developer_transfers": { + "name": "developer_transfers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_developer_transfers_token": { + "name": "idx_developer_transfers_token", + "columns": ["token_hash"], + "isUnique": true + }, + "idx_developer_transfers_pending": { + "name": "idx_developer_transfers_pending", + "columns": ["developer_id"], + "isUnique": true, + "where": "\"developer_transfers\".\"accepted_at\" IS NULL AND \"developer_transfers\".\"revoked_at\" IS NULL" + } + }, + "foreignKeys": { + "developer_transfers_developer_id_developers_id_fk": { + "name": "developer_transfers_developer_id_developers_id_fk", + "tableFrom": "developer_transfers", + "tableTo": "developers", + "columnsFrom": ["developer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_transfers_created_by_users_id_fk": { + "name": "developer_transfers_created_by_users_id_fk", + "tableFrom": "developer_transfers", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_transfers_accepted_by_users_id_fk": { + "name": "developer_transfers_accepted_by_users_id_fk", + "tableFrom": "developer_transfers", + "tableTo": "users", + "columnsFrom": ["accepted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "developers": { + "name": "developers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01T00:00:00.000Z'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01T00:00:00.000Z'" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownership_epoch": { + "name": "ownership_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content_revision": { + "name": "content_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "approved_revision": { + "name": "approved_revision", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_org_verified": { + "name": "github_org_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_verification_note": { + "name": "github_verification_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_developers_owner_unique": { + "name": "idx_developers_owner_unique", + "columns": ["owner_user_id"], + "isUnique": true + }, + "idx_developers_approved": { + "name": "idx_developers_approved", + "columns": ["approved_at"], + "isUnique": false + } + }, + "foreignKeys": { + "developers_owner_user_id_users_id_fk": { + "name": "developers_owner_user_id_users_id_fk", + "tableFrom": "developers", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "developers_ownership_epoch_check": { + "name": "developers_ownership_epoch_check", + "value": "\"developers\".\"ownership_epoch\" >= 1" + }, + "developers_content_revision_check": { + "name": "developers_content_revision_check", + "value": "\"developers\".\"content_revision\" >= 1" + }, + "developers_github_org_verified_check": { + "name": "developers_github_org_verified_check", + "value": "\"developers\".\"github_org_verified\" IN (0, 1)" + } + } + }, + "extension_submissions": { + "name": "extension_submissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownership_epoch": { + "name": "ownership_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_submissions_status": { + "name": "idx_submissions_status", + "columns": ["status"], + "isUnique": false + }, + "idx_submissions_submitted_by": { + "name": "idx_submissions_submitted_by", + "columns": ["submitted_by"], + "isUnique": false + }, + "idx_submissions_developer": { + "name": "idx_submissions_developer", + "columns": ["developer_id"], + "isUnique": false + }, + "idx_submissions_extension": { + "name": "idx_submissions_extension", + "columns": ["extension_id"], + "isUnique": false + }, + "idx_extension_submissions_pending_target": { + "name": "idx_extension_submissions_pending_target", + "columns": ["target_key"], + "isUnique": true, + "where": "\"extension_submissions\".\"status\" = 'pending'" + }, + "idx_extension_submissions_submitter_page": { + "name": "idx_extension_submissions_submitter_page", + "columns": ["submitted_by", "\"created_at\" desc", "\"id\" desc"], + "isUnique": false + }, + "idx_extension_submissions_queue_page": { + "name": "idx_extension_submissions_queue_page", + "columns": ["status", "created_at", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "extension_submissions_extension_id_extensions_id_fk": { + "name": "extension_submissions_extension_id_extensions_id_fk", + "tableFrom": "extension_submissions", + "tableTo": "extensions", + "columnsFrom": ["extension_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "extension_submissions_submitted_by_users_id_fk": { + "name": "extension_submissions_submitted_by_users_id_fk", + "tableFrom": "extension_submissions", + "tableTo": "users", + "columnsFrom": ["submitted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "extension_submissions_reviewer_id_users_id_fk": { + "name": "extension_submissions_reviewer_id_users_id_fk", + "tableFrom": "extension_submissions", + "tableTo": "users", + "columnsFrom": ["reviewer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "extension_submissions_status_check": { + "name": "extension_submissions_status_check", + "value": "\"extension_submissions\".\"status\" IN ('pending', 'approved', 'rejected')" + }, + "extension_submissions_ownership_epoch_check": { + "name": "extension_submissions_ownership_epoch_check", + "value": "\"extension_submissions\".\"ownership_epoch\" >= 1" + } + } + }, + "extensions": { + "name": "extensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "releases": { + "name": "releases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "download_url": { + "name": "download_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_extensions_type": { + "name": "idx_extensions_type", + "columns": ["type"], + "isUnique": false + }, + "idx_extensions_author": { + "name": "idx_extensions_author", + "columns": ["author_id"], + "isUnique": false + } + }, + "foreignKeys": { + "extensions_author_id_developers_id_fk": { + "name": "extensions_author_id_developers_id_fk", + "tableFrom": "extensions", + "tableTo": "developers", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "idx_extension_submissions_submitter_page": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + }, + "\"id\" desc": { + "isExpression": true + } + } + } + } + } +} diff --git a/src/services/extensions/v2/db/migrations/meta/_journal.json b/src/services/extensions/v2/db/migrations/meta/_journal.json new file mode 100644 index 0000000..76b02db --- /dev/null +++ b/src/services/extensions/v2/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 14, + "version": "6", + "when": 1785258941781, + "tag": "0014_drizzle_baseline", + "breakpoints": true + } + ] +} diff --git a/src/services/extensions/v2/db/schema.ts b/src/services/extensions/v2/db/schema.ts new file mode 100644 index 0000000..7ddac23 --- /dev/null +++ b/src/services/extensions/v2/db/schema.ts @@ -0,0 +1,258 @@ +import { sql } from "drizzle-orm"; +import { + sqliteTable, + text, + integer, + index, + uniqueIndex, + check +} from "drizzle-orm/sqlite-core"; + +// Owned by the sibling FOSSBilling/extensions repo (src/lib/db/users.sql +// there), not this repo - only its id is modeled here, purely so other +// tables in this file can express their FK .references(() => users.id). +// This file is drizzle-kit's schema entry point (see +// drizzle.extensions.config.ts), so a fuller definition here would make +// drizzle-kit think it owns and should generate ALTER TABLE users +// migrations, which would be wrong. users-database.ts (the only place that +// reads more than id) imports a separate, non-scanned definition from +// ./external-tables instead. +export const users = sqliteTable("users", { + id: text("id").primaryKey() +}); + +// Owned by v1 (../../v1/db/schema.sql) - v1 only ever reads this table, v2 +// only references its id via FK, so this file is the single schema source +// for the whole DB_EXTENSIONS database and v1's Drizzle queries import +// these table objects rather than redeclaring them. author_id's column +// name is left as-is (v1's own column, not touched by the v2 rename), but +// its target followed developers per migration 0008 - SQLite's +// ALTER TABLE RENAME TO updates other tables' FK references automatically. +export const extensions = sqliteTable( + "extensions", + { + id: text("id").primaryKey(), + type: text("type").notNull(), + authorId: text("author_id") + .notNull() + .references(() => developers.id), + name: text("name").notNull(), + description: text("description").notNull(), + releases: text("releases").notNull(), + website: text("website").notNull(), + license: text("license").notNull(), + iconUrl: text("icon_url"), + readme: text("readme").notNull(), + source: text("source").notNull(), + version: text("version").notNull(), + downloadUrl: text("download_url").notNull() + }, + (table) => [ + index("idx_extensions_type").on(table.type), + index("idx_extensions_author").on(table.authorId) + ] +); + +// v1-owned table (../../v1/db/schema.sql), renamed authors -> developers by +// v2 migration 0008. type/name/url are v1's original columns; everything +// else was added by v2 migrations 0001-0013. bio (added in 0003) was +// dropped in 0010 and is intentionally absent here. +export const developers = sqliteTable( + "developers", + { + id: text("id").primaryKey(), + type: text("type").notNull(), + name: text("name").notNull(), + url: text("url"), + ownerUserId: text("owner_user_id").references(() => users.id), + approvedAt: text("approved_at"), + // Placeholder default from migration 0002 (SQLite rejects non-constant + // ALTER TABLE ADD COLUMN defaults). Every write sets this explicitly + // (see developers-database.ts) - the literal default is never actually + // read, but it's part of the real column definition so it's kept here + // for baseline-diff fidelity against the existing database. + createdAt: text("created_at").notNull().default("1970-01-01T00:00:00.000Z"), + updatedAt: text("updated_at").notNull().default("1970-01-01T00:00:00.000Z"), + avatarUrl: text("avatar_url"), + contactEmail: text("contact_email"), + ownershipEpoch: integer("ownership_epoch").notNull().default(1), + contentRevision: integer("content_revision").notNull().default(1), + approvedRevision: integer("approved_revision"), + approvedBy: text("approved_by"), + githubOrgVerified: integer("github_org_verified"), + githubVerificationNote: text("github_verification_note") + }, + (table) => [ + uniqueIndex("idx_developers_owner_unique").on(table.ownerUserId), + index("idx_developers_approved").on(table.approvedAt), + check( + "developers_ownership_epoch_check", + sql`${table.ownershipEpoch} >= 1` + ), + check( + "developers_content_revision_check", + sql`${table.contentRevision} >= 1` + ), + check( + "developers_github_org_verified_check", + sql`${table.githubOrgVerified} IN (0, 1)` + ) + ] +); + +export const extensionSubmissions = sqliteTable( + "extension_submissions", + { + id: text("id").primaryKey(), + extensionId: text("extension_id").references(() => extensions.id), + // Deliberately NOT a hard FK to developers - a brand-new-developer + // submission names a developer_id that doesn't exist yet until + // approval. See migration 0001 (as author_id) / 0008 (renamed). + developerId: text("developer_id").notNull(), + submittedBy: text("submitted_by") + .notNull() + .references(() => users.id), + status: text("status").notNull().default("pending"), + payload: text("payload").notNull(), + reviewerId: text("reviewer_id").references(() => users.id), + reviewNote: text("review_note"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + reviewedAt: text("reviewed_at"), + ownershipEpoch: integer("ownership_epoch").notNull().default(1), + targetKey: text("target_key") + }, + (table) => [ + index("idx_submissions_status").on(table.status), + index("idx_submissions_submitted_by").on(table.submittedBy), + index("idx_submissions_developer").on(table.developerId), + index("idx_submissions_extension").on(table.extensionId), + uniqueIndex("idx_extension_submissions_pending_target") + .on(table.targetKey) + .where(sql`${table.status} = 'pending'`), + // created_at/id are DESC in the real index (migration 0011). + // SQLiteColumn has no .desc() (confirmed via tsc - that's a pg-core-only + // builder method), so the ordering is expressed as raw SQL fragments + // instead; verified this produces "desc" in the generated SQL during + // the baseline-diff step. + index("idx_extension_submissions_submitter_page").on( + table.submittedBy, + sql`${table.createdAt} desc`, + sql`${table.id} desc` + ), + index("idx_extension_submissions_queue_page").on( + table.status, + table.createdAt, + table.id + ), + check( + "extension_submissions_status_check", + sql`${table.status} IN ('pending', 'approved', 'rejected')` + ), + check( + "extension_submissions_ownership_epoch_check", + sql`${table.ownershipEpoch} >= 1` + ) + ] +); + +// Append-only audit log for developers writes. developer_id deliberately +// has NO FK (migration 0009 dropped it so DELETE /developers/me can hard- +// delete a developer row while the audit trail survives). The append-only +// guarantee is enforced by two triggers (trg_developer_history_no_update/ +// _no_delete) that Drizzle's schema model can't express - they stay as +// hand-written SQL outside schema.ts, applied once and never touched by +// drizzle-kit's diffing. +export const developerHistory = sqliteTable( + "developer_history", + { + id: text("id").primaryKey(), + developerId: text("developer_id").notNull(), + type: text("type").notNull(), + name: text("name").notNull(), + url: text("url"), + changedBy: text("changed_by") + .notNull() + .references(() => users.id), + changedAt: text("changed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`) + }, + (table) => [ + index("idx_developer_history_developer_changed_at").on( + table.developerId, + table.changedAt + ) + ] +); + +export const developerTransfers = sqliteTable( + "developer_transfers", + { + id: text("id").primaryKey(), + developerId: text("developer_id") + .notNull() + .references(() => developers.id), + tokenHash: text("token_hash").notNull(), + createdBy: text("created_by") + .notNull() + .references(() => users.id), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + acceptedBy: text("accepted_by").references(() => users.id), + acceptedAt: text("accepted_at"), + revokedAt: text("revoked_at") + }, + (table) => [ + uniqueIndex("idx_developer_transfers_token").on(table.tokenHash), + // At most one active (not accepted, not revoked) transfer per developer. + uniqueIndex("idx_developer_transfers_pending") + .on(table.developerId) + .where(sql`${table.acceptedAt} IS NULL AND ${table.revokedAt} IS NULL`) + ] +); + +export const developerClaims = sqliteTable( + "developer_claims", + { + id: text("id").primaryKey(), + developerId: text("developer_id") + .notNull() + .references(() => developers.id), + claimantId: text("claimant_id") + .notNull() + .references(() => users.id), + status: text("status").notNull().default("pending"), + note: text("note"), + reviewNote: text("review_note"), + reviewerId: text("reviewer_id").references(() => users.id), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + reviewedAt: text("reviewed_at"), + githubOrgVerified: integer("github_org_verified"), + githubVerificationNote: text("github_verification_note") + }, + (table) => [ + index("idx_developer_claims_developer").on(table.developerId), + index("idx_developer_claims_claimant").on(table.claimantId), + // One pending claim per (developer, claimant) at a time. + uniqueIndex("idx_developer_claims_pending_unique") + .on(table.developerId, table.claimantId) + .where(sql`${table.status} = 'pending'`), + index("idx_developer_claims_pending_queue") + .on(table.createdAt) + .where(sql`${table.status} = 'pending'`), + check( + "developer_claims_status_check", + sql`${table.status} IN ('pending', 'approved', 'rejected')` + ), + check( + "developer_claims_github_org_verified_check", + sql`${table.githubOrgVerified} IN (0, 1)` + ) + ] +); diff --git a/src/services/extensions/v2/developers-database.ts b/src/services/extensions/v2/developers-database.ts index ef20188..c5aea23 100644 --- a/src/services/extensions/v2/developers-database.ts +++ b/src/services/extensions/v2/developers-database.ts @@ -1,9 +1,16 @@ +import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; +import { DatabaseError, DatabaseResult } from "../../../lib/interfaces"; +import { ExtensionsDb } from "../../../lib/db"; import { - DatabaseError, - DatabaseResult, - IDatabase -} from "../../../lib/interfaces"; -import { databaseError } from "./errors"; + developers, + developerHistory, + developerTransfers, + developerClaims, + extensions, + extensionSubmissions +} from "./db/schema"; +import { databaseError, errorMessageChain } from "./errors"; +import { toD1Statement } from "./d1-batch"; import { checkGithubEntityType, matchesClaimant } from "./github-verification"; import { Developer, @@ -18,16 +25,18 @@ import { UsersDatabase } from "./users-database"; // Matches the SQLite/D1 message for the idx_developers_owner_unique // violation, which is how a lost race between two concurrent first-time PUT // /developers/me requests (same caller, different ids) surfaces. -function isOwnerConflict(message: string | undefined): boolean { - return !!message && /UNIQUE constraint failed.*owner_user_id/i.test(message); +function isOwnerConflict(error: unknown): boolean { + return /UNIQUE constraint failed.*owner_user_id/i.test( + errorMessageChain(error) + ); } // Matches the SQLite/D1 message for the idx_developer_claims_pending_unique // violation, which is how a duplicate claim() call while one is already // pending surfaces. -function isPendingClaimConflict(message: string | undefined): boolean { - return ( - !!message && /UNIQUE constraint failed.*developer_claims/i.test(message) +function isPendingClaimConflict(error: unknown): boolean { + return /UNIQUE constraint failed.*developer_claims/i.test( + errorMessageChain(error) ); } @@ -52,55 +61,52 @@ function toSqliteDatetime(date: Date): string { return date.toISOString().slice(0, 19).replace("T", " "); } -function parseDeveloperRow(row: Record): DeveloperProfile { +type DeveloperRow = typeof developers.$inferSelect; +type ClaimRow = typeof developerClaims.$inferSelect; + +function parseDeveloperRow(row: DeveloperRow): DeveloperProfile { return { - id: row.id as string, + id: row.id, type: row.type as DeveloperProfile["type"], - name: row.name as string, - URL: (row.url as string | null) ?? undefined, - avatar_url: (row.avatar_url as string | null) ?? undefined, - contact_email: (row.contact_email as string | null) ?? undefined, + name: row.name, + URL: row.url ?? undefined, + avatar_url: row.avatarUrl ?? undefined, + contact_email: row.contactEmail ?? undefined, approved: - row.approved_at !== null && - row.approved_at !== undefined && - (row.approved_revision == null || - Number(row.approved_revision) === Number(row.content_revision ?? 1)), - content_revision: Number(row.content_revision ?? 1), + row.approvedAt !== null && + row.approvedAt !== undefined && + (row.approvedRevision == null || + Number(row.approvedRevision) === Number(row.contentRevision ?? 1)), + content_revision: Number(row.contentRevision ?? 1), github_org_verified: - row.github_org_verified === null || row.github_org_verified === undefined + row.githubOrgVerified === null || row.githubOrgVerified === undefined ? undefined - : row.github_org_verified === 1, - github_verification_note: - (row.github_verification_note as string | null) ?? undefined + : row.githubOrgVerified === 1, + github_verification_note: row.githubVerificationNote ?? undefined }; } -function parseClaimRow(row: Record): DeveloperClaim { +function parseClaimRow(row: ClaimRow): DeveloperClaim { return { - id: row.id as string, - developer_id: row.developer_id as string, - claimant_id: row.claimant_id as string, + id: row.id, + developer_id: row.developerId, + claimant_id: row.claimantId, status: row.status as DeveloperClaim["status"], - note: (row.note as string | null) ?? undefined, - review_note: (row.review_note as string | null) ?? undefined, - reviewer_id: (row.reviewer_id as string | null) ?? undefined, - created_at: row.created_at as string, - reviewed_at: (row.reviewed_at as string | null) ?? undefined, + note: row.note ?? undefined, + review_note: row.reviewNote ?? undefined, + reviewer_id: row.reviewerId ?? undefined, + created_at: row.createdAt, + reviewed_at: row.reviewedAt ?? undefined, github_org_verified: - row.github_org_verified === null || row.github_org_verified === undefined + row.githubOrgVerified === null || row.githubOrgVerified === undefined ? undefined - : row.github_org_verified === 1, - github_verification_note: - (row.github_verification_note as string | null) ?? undefined + : row.githubOrgVerified === 1, + github_verification_note: row.githubVerificationNote ?? undefined }; } export class DevelopersDatabase { - private db: IDatabase; - - constructor(db: IDatabase) { - this.db = db; - } + constructor(private db: ExtensionsDb) {} // githubToken — see the comment on verifyGithubOwnership(). Only consulted // when creating a brand-new profile (developer.id is immutable once @@ -114,15 +120,15 @@ export class DevelopersDatabase { githubToken?: string ): Promise> { try { - const existingOwn = await this.db - .prepare("SELECT * FROM developers WHERE owner_user_id = ?") - .bind(userId) - .first>(); + const [existingOwn] = await this.db + .select() + .from(developers) + .where(eq(developers.ownerUserId, userId)); - const existingById = await this.db - .prepare("SELECT * FROM developers WHERE id = ?") - .bind(developer.id) - .first>(); + const [existingById] = await this.db + .select() + .from(developers) + .where(eq(developers.id, developer.id)); let githubOrgVerified: number | null = null; let githubVerificationNote: string | null = null; @@ -168,22 +174,20 @@ export class DevelopersDatabase { githubOrgVerified = check.githubOrgVerified; githubVerificationNote = check.note; - mainStmt = this.db - .prepare( - `INSERT INTO developers (id, type, name, url, avatar_url, contact_email, owner_user_id, approved_at, github_org_verified, github_verification_note, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` - ) - .bind( - developer.id, - developer.type, - developer.name, - developer.URL ?? null, - developer.avatar_url ?? null, - developer.contact_email ?? null, - userId, - githubOrgVerified, - githubVerificationNote - ); + mainStmt = this.db.insert(developers).values({ + id: developer.id, + type: developer.type, + name: developer.name, + url: developer.URL ?? null, + avatarUrl: developer.avatar_url ?? null, + contactEmail: developer.contact_email ?? null, + ownerUserId: userId, + approvedAt: null, + githubOrgVerified, + githubVerificationNote, + createdAt: sql`CURRENT_TIMESTAMP`, + updatedAt: sql`CURRENT_TIMESTAMP` + }); } else { if (developer.id !== existingOwn.id) { return { @@ -199,56 +203,58 @@ export class DevelopersDatabase { // changed — the reviewed content just got overwritten, so the old // approval no longer applies. Not worth diffing old vs. new values. mainStmt = this.db - .prepare( - `UPDATE developers - SET type = ?, name = ?, url = ?, avatar_url = ?, contact_email = ?, - content_revision = content_revision + 1, - approved_at = NULL, approved_revision = NULL, approved_by = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND owner_user_id = ?` - ) - .bind( - developer.type, - developer.name, - developer.URL ?? null, - developer.avatar_url ?? null, - developer.contact_email ?? null, - developer.id, - userId + .update(developers) + .set({ + type: developer.type, + name: developer.name, + url: developer.URL ?? null, + avatarUrl: developer.avatar_url ?? null, + contactEmail: developer.contact_email ?? null, + contentRevision: sql`content_revision + 1`, + approvedAt: null, + approvedRevision: null, + approvedBy: null, + updatedAt: sql`CURRENT_TIMESTAMP` + }) + .where( + and( + eq(developers.id, developer.id), + eq(developers.ownerUserId, userId) + ) ); } - if (!this.db.batch) { - return databaseError( - "upsertOwn", - new Error("Database adapter does not support batch operations") - ); - } - - const historyStmt = this.db - .prepare( - `INSERT INTO developer_history (id, developer_id, type, name, url, changed_by, changed_at) - SELECT ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP - WHERE changes() = 1` - ) - .bind( + // Batched via the raw D1 client ($client - see toD1Statement's + // comment): drizzle-orm 0.45.2's D1 batch() throws + // "Cannot read properties of undefined (reading 'bind')" for any + // db.run(sql\`...\`) item that has bound params (confirmed via an + // isolated repro against real D1 - its prepared-query wrapper for + // raw sql lacks the .stmt property batch() unconditionally reads). + // Gated on changes() = 1 (the immediately preceding batch statement) + // rather than a query-builder insert, since there's no FROM table to + // build this against - it's a conditional literal-values insert. + const historyStmt = toD1Statement(this.db.$client, { + sql: `INSERT INTO developer_history (id, developer_id, type, name, url, changed_by, changed_at) + SELECT ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP + WHERE changes() = 1`, + params: [ crypto.randomUUID(), developer.id, developer.type, developer.name, developer.URL ?? null, userId - ); + ] + }); let results; try { - results = (await this.db.batch([mainStmt, historyStmt])) as Array<{ - success: boolean; - error?: string; - meta?: { changes?: number }; - }>; + results = await this.db.$client.batch([ + toD1Statement(this.db.$client, mainStmt.toSQL()), + historyStmt + ]); } catch (error) { - if (isOwnerConflict(error instanceof Error ? error.message : "")) { + if (isOwnerConflict(error)) { return { data: null, error: { @@ -260,23 +266,6 @@ export class DevelopersDatabase { return databaseError("upsertOwn", error); } - const failed = results.find((r) => !r.success); - if (failed) { - if (isOwnerConflict(failed.error)) { - return { - data: null, - error: { - message: "You already have a developer profile", - code: "CONFLICT" - } - }; - } - return databaseError( - "upsertOwn", - new Error(failed.error || "Database write failed") - ); - } - if (!results[0]?.meta?.changes) { return { data: null, @@ -287,10 +276,15 @@ export class DevelopersDatabase { }; } - const current = await this.db - .prepare("SELECT * FROM developers WHERE id = ? AND owner_user_id = ?") - .bind(developer.id, userId) - .first>(); + const [current] = await this.db + .select() + .from(developers) + .where( + and( + eq(developers.id, developer.id), + eq(developers.ownerUserId, userId) + ) + ); if (!current) { return { data: null, @@ -313,19 +307,19 @@ export class DevelopersDatabase { developerId: string, userId: string ): Promise<{ code: "NOT_FOUND" | "CONFLICT"; message: string }> { - const developer = await this.db - .prepare("SELECT owner_user_id FROM developers WHERE id = ?") - .bind(developerId) - .first<{ owner_user_id: string | null }>(); + const [developer] = await this.db + .select({ ownerUserId: developers.ownerUserId }) + .from(developers) + .where(eq(developers.id, developerId)); - if (!developer || developer.owner_user_id !== userId) { + if (!developer || developer.ownerUserId !== userId) { return { code: "NOT_FOUND", message: "Developer not found" }; } - const extensionCount = await this.db - .prepare("SELECT COUNT(*) AS count FROM extensions WHERE author_id = ?") - .bind(developerId) - .first<{ count: number }>(); + const [extensionCount] = await this.db + .select({ count: sql`COUNT(*)` }) + .from(extensions) + .where(eq(extensions.authorId, developerId)); const extensionsCount = extensionCount?.count ?? 0; if (extensionsCount > 0) { return { @@ -334,12 +328,15 @@ export class DevelopersDatabase { }; } - const pendingCount = await this.db - .prepare( - "SELECT COUNT(*) AS count FROM extension_submissions WHERE developer_id = ? AND status = 'pending'" - ) - .bind(developerId) - .first<{ count: number }>(); + const [pendingCount] = await this.db + .select({ count: sql`COUNT(*)` }) + .from(extensionSubmissions) + .where( + and( + eq(extensionSubmissions.developerId, developerId), + eq(extensionSubmissions.status, "pending") + ) + ); if ((pendingCount?.count ?? 0) > 0) { return { code: "CONFLICT", @@ -372,10 +369,10 @@ export class DevelopersDatabase { userId: string ): Promise> { try { - const developer = await this.db - .prepare("SELECT id FROM developers WHERE owner_user_id = ?") - .bind(userId) - .first<{ id: string }>(); + const [developer] = await this.db + .select({ id: developers.id }) + .from(developers) + .where(eq(developers.ownerUserId, userId)); if (!developer) { return { @@ -384,13 +381,6 @@ export class DevelopersDatabase { }; } - if (!this.db.batch) { - return databaseError( - "deleteOwn", - new Error("Database adapter does not support batch operations") - ); - } - // Every statement re-checks eligibility (still owned by this caller, // no published extensions, no pending submission) at the moment it // runs, rather than trusting the SELECT above: ownership can move @@ -400,80 +390,69 @@ export class DevelopersDatabase { // repeated on all three statements — not just the last — so they're // all-or-nothing: if it fails, nothing here is touched, instead of // transfers/claims being deleted out from under a profile whose own - // deletion then gets blocked. - const deleteTransfersStmt = this.db - .prepare( - `DELETE FROM developer_transfers - WHERE developer_id = ? - AND EXISTS ( - SELECT 1 FROM developers - WHERE developers.id = developer_transfers.developer_id - AND developers.owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) - AND NOT EXISTS ( - SELECT 1 FROM extension_submissions - WHERE extension_submissions.developer_id = developers.id - AND extension_submissions.status = 'pending' - ) - )` - ) - .bind(developer.id, userId); - - const deleteClaimsStmt = this.db - .prepare( - `DELETE FROM developer_claims - WHERE developer_id = ? - AND EXISTS ( - SELECT 1 FROM developers - WHERE developers.id = developer_claims.developer_id - AND developers.owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) - AND NOT EXISTS ( - SELECT 1 FROM extension_submissions - WHERE extension_submissions.developer_id = developers.id - AND extension_submissions.status = 'pending' - ) - )` - ) - .bind(developer.id, userId); - - const deleteDeveloperStmt = this.db - .prepare( - `DELETE FROM developers - WHERE id = ? - AND owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) - AND NOT EXISTS ( - SELECT 1 FROM extension_submissions - WHERE extension_submissions.developer_id = developers.id - AND extension_submissions.status = 'pending' - )` - ) - .bind(developer.id, userId); + // deletion then gets blocked. Kept as raw sql via $client (see + // toD1Statement): the correlated EXISTS subqueries reference the + // outer statement's own table name, which the query builder can't + // express, and this batch needs the raw-D1 escape hatch regardless + // (see upsertOwn's historyStmt comment). + const deleteTransfersStmt = toD1Statement(this.db.$client, { + sql: `DELETE FROM developer_transfers + WHERE developer_id = ? + AND EXISTS ( + SELECT 1 FROM developers + WHERE developers.id = developer_transfers.developer_id + AND developers.owner_user_id = ? + AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) + AND NOT EXISTS ( + SELECT 1 FROM extension_submissions + WHERE extension_submissions.developer_id = developers.id + AND extension_submissions.status = 'pending' + ) + )`, + params: [developer.id, userId] + }); + + const deleteClaimsStmt = toD1Statement(this.db.$client, { + sql: `DELETE FROM developer_claims + WHERE developer_id = ? + AND EXISTS ( + SELECT 1 FROM developers + WHERE developers.id = developer_claims.developer_id + AND developers.owner_user_id = ? + AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) + AND NOT EXISTS ( + SELECT 1 FROM extension_submissions + WHERE extension_submissions.developer_id = developers.id + AND extension_submissions.status = 'pending' + ) + )`, + params: [developer.id, userId] + }); + + const deleteDeveloperStmt = toD1Statement(this.db.$client, { + sql: `DELETE FROM developers + WHERE id = ? + AND owner_user_id = ? + AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) + AND NOT EXISTS ( + SELECT 1 FROM extension_submissions + WHERE extension_submissions.developer_id = developers.id + AND extension_submissions.status = 'pending' + )`, + params: [developer.id, userId] + }); let results; try { - results = (await this.db.batch([ + results = await this.db.$client.batch([ deleteTransfersStmt, deleteClaimsStmt, deleteDeveloperStmt - ])) as Array<{ - success: boolean; - error?: string; - meta?: { changes?: number }; - }>; + ]); } catch (error) { return databaseError("deleteOwn", error); } - const failed = results.find((r) => !r.success); - if (failed) { - return databaseError( - "deleteOwn", - new Error(failed.error || "Database write failed") - ); - } - const [, , developerResult] = results; if (!developerResult.meta?.changes) { return { @@ -490,10 +469,10 @@ export class DevelopersDatabase { async getById(id: string): Promise> { try { - const row = await this.db - .prepare("SELECT * FROM developers WHERE id = ?") - .bind(id) - .first>(); + const [row] = await this.db + .select() + .from(developers) + .where(eq(developers.id, id)); if (!row) { return { data: null, @@ -510,51 +489,32 @@ export class DevelopersDatabase { } async listAll(): Promise> { - let result; + let rows; try { - result = await this.db - .prepare("SELECT * FROM developers ORDER BY name") - .all>(); + rows = await this.db + .select() + .from(developers) + .orderBy(asc(developers.name)); } catch (error) { return databaseError("listAll", error); } - if (!result.success) { - return databaseError( - "listAll", - new Error(result.error || "Database query failed") - ); - } - - return { - data: (result.results ?? []).map(parseDeveloperRow), - error: null - }; + return { data: rows.map(parseDeveloperRow), error: null }; } async listUnapproved(): Promise> { - let result; + let rows; try { - result = await this.db - .prepare( - "SELECT * FROM developers WHERE approved_at IS NULL ORDER BY created_at ASC" - ) - .all>(); + rows = await this.db + .select() + .from(developers) + .where(isNull(developers.approvedAt)) + .orderBy(asc(developers.createdAt)); } catch (error) { return databaseError("listUnapproved", error); } - if (!result.success) { - return databaseError( - "listUnapproved", - new Error(result.error || "Database query failed") - ); - } - - return { - data: (result.results ?? []).map(parseDeveloperRow), - error: null - }; + return { data: rows.map(parseDeveloperRow), error: null }; } async approve( @@ -565,26 +525,22 @@ export class DevelopersDatabase { let result; try { result = await this.db - .prepare( - `UPDATE developers - SET approved_at = CURRENT_TIMESTAMP, - approved_revision = content_revision, - approved_by = ? - WHERE id = ? AND content_revision = ?` - ) - .bind(reviewerId, id, expectedRevision) - .run(); + .update(developers) + .set({ + approvedAt: sql`CURRENT_TIMESTAMP`, + approvedRevision: sql`content_revision`, + approvedBy: reviewerId + }) + .where( + and( + eq(developers.id, id), + eq(developers.contentRevision, expectedRevision) + ) + ); } catch (error) { return databaseError("approve", error); } - if (!result.success) { - return databaseError( - "approve", - new Error(result.error || "Database query failed") - ); - } - if (!result.meta?.changes) { const existing = await this.getById(id); return existing.error @@ -611,38 +567,36 @@ export class DevelopersDatabase { async listHistory( developerId: string ): Promise> { - let result; + let rows; try { - result = await this.db - .prepare( - // CURRENT_TIMESTAMP has only second resolution, so two writes in - // the same second tie on changed_at; rowid (insertion order) - // breaks the tie so "newest first" is never ambiguous. - `SELECT developer_id, type, name, url, changed_by, changed_at - FROM developer_history WHERE developer_id = ? - ORDER BY changed_at DESC, rowid DESC` - ) - .bind(developerId) - .all>(); + rows = await this.db + .select({ + developerId: developerHistory.developerId, + type: developerHistory.type, + name: developerHistory.name, + url: developerHistory.url, + changedBy: developerHistory.changedBy, + changedAt: developerHistory.changedAt + }) + .from(developerHistory) + .where(eq(developerHistory.developerId, developerId)) + // CURRENT_TIMESTAMP has only second resolution, so two writes in + // the same second tie on changed_at; rowid (insertion order, + // implicit - not a declared schema column) breaks the tie so + // "newest first" is never ambiguous. + .orderBy(desc(developerHistory.changedAt), sql`rowid DESC`); } catch (error) { return databaseError("listHistory", error); } - if (!result.success) { - return databaseError( - "listHistory", - new Error(result.error || "Database query failed") - ); - } - return { - data: (result.results ?? []).map((row) => ({ - developer_id: row.developer_id as string, + data: rows.map((row) => ({ + developer_id: row.developerId, type: row.type as DeveloperHistoryEntry["type"], - name: row.name as string, - URL: (row.url as string | null) ?? undefined, - changed_by: row.changed_by as string, - changed_at: row.changed_at as string + name: row.name, + URL: row.url ?? undefined, + changed_by: row.changedBy, + changed_at: row.changedAt })), error: null }; @@ -654,15 +608,15 @@ export class DevelopersDatabase { developerId: string, userId: string ): Promise<{ code: "NOT_FOUND" | "FORBIDDEN"; message: string } | null> { - const owner = await this.db - .prepare("SELECT owner_user_id FROM developers WHERE id = ?") - .bind(developerId) - .first<{ owner_user_id: string | null }>(); + const [owner] = await this.db + .select({ ownerUserId: developers.ownerUserId }) + .from(developers) + .where(eq(developers.id, developerId)); if (!owner) { return { code: "NOT_FOUND", message: "Developer not found" }; } - if (owner.owner_user_id !== userId) { + if (owner.ownerUserId !== userId) { return { code: "FORBIDDEN", message: "You don't own this profile" }; } return null; @@ -679,34 +633,27 @@ export class DevelopersDatabase { const tokenHash = await sha256Hex(token); const expiresAt = toSqliteDatetime(new Date(Date.now() + 60 * 60 * 1000)); - if (!this.db.batch) { - return databaseError( - "initiateTransfer", - new Error("Database adapter does not support batch operations") - ); - } - // Both writes are conditioned on current ownership in the same // statement, rather than a separate SELECT beforehand — a caller who // loses ownership between an up-front check and the write could // otherwise still slip the write through. Superseding any existing // pending transfer (rather than stacking up) keeps // idx_developer_transfers_pending satisfied without a separate cleanup - // pass. - const revokeStmt = this.db - .prepare( - `UPDATE developer_transfers SET revoked_at = CURRENT_TIMESTAMP - WHERE developer_id = ? AND accepted_at IS NULL AND revoked_at IS NULL - AND EXISTS (SELECT 1 FROM developers WHERE developers.id = developer_transfers.developer_id AND developers.owner_user_id = ?)` - ) - .bind(developerId, userId); - const insertStmt = this.db - .prepare( - `INSERT INTO developer_transfers (id, developer_id, token_hash, created_by, expires_at) - SELECT ?, ?, ?, ?, ? - WHERE EXISTS (SELECT 1 FROM developers WHERE id = ? AND owner_user_id = ?)` - ) - .bind( + // pass. Kept as raw sql via $client (see toD1Statement): the EXISTS + // subqueries are correlated against the outer table's own name, and + // this batch needs the raw-D1 escape hatch regardless (see + // upsertOwn's historyStmt comment). + const revokeStmt = toD1Statement(this.db.$client, { + sql: `UPDATE developer_transfers SET revoked_at = CURRENT_TIMESTAMP + WHERE developer_id = ? AND accepted_at IS NULL AND revoked_at IS NULL + AND EXISTS (SELECT 1 FROM developers WHERE developers.id = developer_transfers.developer_id AND developers.owner_user_id = ?)`, + params: [developerId, userId] + }); + const insertStmt = toD1Statement(this.db.$client, { + sql: `INSERT INTO developer_transfers (id, developer_id, token_hash, created_by, expires_at) + SELECT ?, ?, ?, ?, ? + WHERE EXISTS (SELECT 1 FROM developers WHERE id = ? AND owner_user_id = ?)`, + params: [ crypto.randomUUID(), developerId, tokenHash, @@ -714,20 +661,10 @@ export class DevelopersDatabase { expiresAt, developerId, userId - ); + ] + }); - const results = (await this.db.batch([revokeStmt, insertStmt])) as Array<{ - success: boolean; - error?: string; - meta?: { changes?: number }; - }>; - const failed = results.find((r) => !r.success); - if (failed) { - return databaseError( - "initiateTransfer", - new Error(failed.error || "Database write failed") - ); - } + const results = await this.db.$client.batch([revokeStmt, insertStmt]); // The INSERT only writes a row when the ownership guard above passes, // so zero rows written means the caller doesn't currently own this @@ -755,21 +692,11 @@ export class DevelopersDatabase { userId: string ): Promise> { try { - const result = await this.db - .prepare( - `UPDATE developer_transfers SET revoked_at = CURRENT_TIMESTAMP - WHERE developer_id = ? AND accepted_at IS NULL AND revoked_at IS NULL - AND EXISTS (SELECT 1 FROM developers WHERE developers.id = developer_transfers.developer_id AND developers.owner_user_id = ?)` - ) - .bind(developerId, userId) - .run(); - - if (!result.success) { - return databaseError( - "revokeTransfer", - new Error(result.error || "Database write failed") - ); - } + const result = await this.db.run(sql` + UPDATE ${developerTransfers} SET revoked_at = CURRENT_TIMESTAMP + WHERE developer_id = ${developerId} AND accepted_at IS NULL AND revoked_at IS NULL + AND EXISTS (SELECT 1 FROM ${developers} WHERE developers.id = developer_transfers.developer_id AND developers.owner_user_id = ${userId}) + `); // Zero rows changed is ambiguous by itself (no pending transfer vs. // not the owner vs. no such developer), since the ownership guard is @@ -796,13 +723,6 @@ export class DevelopersDatabase { try { const tokenHash = await sha256Hex(token); - if (!this.db.batch) { - return databaseError( - "acceptTransfer", - new Error("Database adapter does not support batch operations") - ); - } - // Claim the transfer and move ownership in the same atomic batch, // rather than as two separate writes. Splitting them would leave a // window, after the claim commits but before ownership actually @@ -832,57 +752,53 @@ export class DevelopersDatabase { // plain check-then-act (SELECT the row, decide, then write) would let // two concurrent accepts both read it as valid before either one // wrote to it, making the token usable more than once. - const claimStmt = this.db - .prepare( - `UPDATE developer_transfers SET accepted_at = CURRENT_TIMESTAMP, accepted_by = ? - WHERE token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > CURRENT_TIMESTAMP - AND NOT EXISTS (SELECT 1 FROM developers WHERE owner_user_id = ?)` - ) - .bind(userId, tokenHash, userId); - const updateDeveloperStmt = this.db - .prepare( - `UPDATE developers - SET owner_user_id = ?, - ownership_epoch = ownership_epoch + 1, - content_revision = content_revision + 1, - approved_at = NULL, approved_revision = NULL, approved_by = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE changes() = 1 - AND id = ( - SELECT developer_id FROM developer_transfers - WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL - )` - ) - .bind(userId, tokenHash, userId); - - const rejectPendingStmt = this.db - .prepare( - `UPDATE extension_submissions - SET status = 'rejected', - review_note = 'Ownership changed before review', - reviewed_at = CURRENT_TIMESTAMP - WHERE changes() = 1 - AND developer_id = ( - SELECT developer_id FROM developer_transfers - WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL - ) - AND status = 'pending'` - ) - .bind(tokenHash, userId); + // Kept as raw sql via $client (see toD1Statement): correlated + // subqueries plus the changes()=1 gates need the raw-D1 escape hatch + // (see upsertOwn's historyStmt comment). + const claimStmt = toD1Statement(this.db.$client, { + sql: `UPDATE developer_transfers SET accepted_at = CURRENT_TIMESTAMP, accepted_by = ? + WHERE token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > CURRENT_TIMESTAMP + AND NOT EXISTS (SELECT 1 FROM developers WHERE owner_user_id = ?)`, + params: [userId, tokenHash, userId] + }); + const updateDeveloperStmt = toD1Statement(this.db.$client, { + sql: `UPDATE developers + SET owner_user_id = ?, + ownership_epoch = ownership_epoch + 1, + content_revision = content_revision + 1, + approved_at = NULL, approved_revision = NULL, approved_by = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE changes() = 1 + AND id = ( + SELECT developer_id FROM developer_transfers + WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL + )`, + params: [userId, tokenHash, userId] + }); + + const rejectPendingStmt = toD1Statement(this.db.$client, { + sql: `UPDATE extension_submissions + SET status = 'rejected', + review_note = 'Ownership changed before review', + reviewed_at = CURRENT_TIMESTAMP + WHERE changes() = 1 + AND developer_id = ( + SELECT developer_id FROM developer_transfers + WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL + ) + AND status = 'pending'`, + params: [tokenHash, userId] + }); let results; try { - results = (await this.db.batch([ + results = await this.db.$client.batch([ claimStmt, updateDeveloperStmt, rejectPendingStmt - ])) as Array<{ - success: boolean; - error?: string; - meta?: { changes?: number }; - }>; + ]); } catch (error) { - if (isOwnerConflict(error instanceof Error ? error.message : "")) { + if (isOwnerConflict(error)) { return { data: null, error: { @@ -895,36 +811,22 @@ export class DevelopersDatabase { return databaseError("acceptTransfer", error); } - const failed = results.find((r) => !r.success); - if (failed) { - if (isOwnerConflict(failed.error)) { - return { - data: null, - error: { - code: "CONFLICT", - message: - "You already have a developer profile — remove or transfer it before accepting a new one" - } - }; - } - return databaseError( - "acceptTransfer", - new Error(failed.error || "Database write failed") - ); - } - const [claim] = results; if (!claim.meta?.changes) { // The claim can fail either because the token itself is bad (used, // revoked, expired, unknown) or because it's still valid but the // ownership guard rejected it — check which, for an accurate error. - const stillPending = await this.db - .prepare( - `SELECT 1 FROM developer_transfers - WHERE token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > CURRENT_TIMESTAMP` - ) - .bind(tokenHash) - .first(); + const [stillPending] = await this.db + .select({ one: sql`1` }) + .from(developerTransfers) + .where( + and( + eq(developerTransfers.tokenHash, tokenHash), + isNull(developerTransfers.acceptedAt), + isNull(developerTransfers.revokedAt), + sql`${developerTransfers.expiresAt} > CURRENT_TIMESTAMP` + ) + ); if (stillPending) { return { @@ -945,12 +847,10 @@ export class DevelopersDatabase { }; } - const transfer = await this.db - .prepare( - "SELECT developer_id FROM developer_transfers WHERE token_hash = ?" - ) - .bind(tokenHash) - .first<{ developer_id: string }>(); + const [transfer] = await this.db + .select({ developerId: developerTransfers.developerId }) + .from(developerTransfers) + .where(eq(developerTransfers.tokenHash, tokenHash)); if (!transfer) { return databaseError( "acceptTransfer", @@ -958,7 +858,7 @@ export class DevelopersDatabase { ); } - return this.getById(transfer.developer_id); + return this.getById(transfer.developerId); } catch (error) { return databaseError("acceptTransfer", error); } @@ -968,10 +868,10 @@ export class DevelopersDatabase { id: string ): Promise> { try { - const row = await this.db - .prepare("SELECT * FROM developer_claims WHERE id = ?") - .bind(id) - .first>(); + const [row] = await this.db + .select() + .from(developerClaims) + .where(eq(developerClaims.id, id)); if (!row) { return { data: null, @@ -994,14 +894,14 @@ export class DevelopersDatabase { private async claimIneligibilityError( developerId: string ): Promise<{ code: "NOT_FOUND" | "CONFLICT"; message: string }> { - const developer = await this.db - .prepare("SELECT owner_user_id FROM developers WHERE id = ?") - .bind(developerId) - .first<{ owner_user_id: string | null }>(); + const [developer] = await this.db + .select({ ownerUserId: developers.ownerUserId }) + .from(developers) + .where(eq(developers.id, developerId)); if (!developer) { return { code: "NOT_FOUND", message: "Developer not found" }; } - if (developer.owner_user_id !== null) { + if (developer.ownerUserId !== null) { return { code: "CONFLICT", message: "This profile is already owned" }; } return { @@ -1099,12 +999,12 @@ export class DevelopersDatabase { let githubOrgVerified: number | null = null; let githubVerificationNote: string | null = null; - const developer = await this.db - .prepare( - "SELECT type FROM developers WHERE id = ? AND owner_user_id IS NULL" - ) - .bind(developerId) - .first<{ type: Developer["type"] }>(); + const [developer] = await this.db + .select({ type: developers.type }) + .from(developers) + .where( + and(eq(developers.id, developerId), isNull(developers.ownerUserId)) + ); if (developer) { // Cheap short-circuit ahead of the GitHub lookup below: a claimant @@ -1121,12 +1021,16 @@ export class DevelopersDatabase { // read and the response going out can make the message stale // relative to that instant, but never lets a row get created // without verification. - const hasPendingClaim = await this.db - .prepare( - "SELECT 1 FROM developer_claims WHERE developer_id = ? AND claimant_id = ? AND status = 'pending'" - ) - .bind(developerId, claimantId) - .first(); + const [hasPendingClaim] = await this.db + .select({ one: sql`1` }) + .from(developerClaims) + .where( + and( + eq(developerClaims.developerId, developerId), + eq(developerClaims.claimantId, claimantId), + eq(developerClaims.status, "pending") + ) + ); if (hasPendingClaim) { return { @@ -1140,7 +1044,7 @@ export class DevelopersDatabase { const check = await this.verifyGithubOwnership( developerId, - developer.type, + developer.type as Developer["type"], claimantId, githubToken ); @@ -1173,29 +1077,17 @@ export class DevelopersDatabase { // different profile) between an up-front check and the write could // otherwise still slip a stale claim through. (The SELECT above is // only used to decide the GitHub verification signal, and is always - // re-checked here — it can't itself grant eligibility.) - result = await this.db - .prepare( - `INSERT INTO developer_claims (id, developer_id, claimant_id, note, github_org_verified, github_verification_note) - SELECT ?, ?, ?, ?, ?, ? - WHERE EXISTS (SELECT 1 FROM developers WHERE id = ? AND owner_user_id IS NULL) - AND NOT EXISTS (SELECT 1 FROM developers WHERE owner_user_id = ?)` - ) - .bind( - id, - developerId, - claimantId, - note ?? null, - githubOrgVerified, - githubVerificationNote, - developerId, - claimantId - ) - .run(); + // re-checked here — it can't itself grant eligibility.) Kept as raw + // sql: an INSERT...SELECT...WHERE EXISTS isn't expressible via + // .insert().values(). + result = await this.db.run(sql` + INSERT INTO ${developerClaims} (id, developer_id, claimant_id, note, github_org_verified, github_verification_note) + SELECT ${id}, ${developerId}, ${claimantId}, ${note ?? null}, ${githubOrgVerified}, ${githubVerificationNote} + WHERE EXISTS (SELECT 1 FROM ${developers} WHERE id = ${developerId} AND owner_user_id IS NULL) + AND NOT EXISTS (SELECT 1 FROM ${developers} WHERE owner_user_id = ${claimantId}) + `); } catch (error) { - if ( - isPendingClaimConflict(error instanceof Error ? error.message : "") - ) { + if (isPendingClaimConflict(error)) { return { data: null, error: { @@ -1207,13 +1099,6 @@ export class DevelopersDatabase { return databaseError("claim", error); } - if (!result.success) { - return databaseError( - "claim", - new Error(result.error || "Database write failed") - ); - } - if (!result.meta?.changes) { return { data: null, @@ -1237,22 +1122,18 @@ export class DevelopersDatabase { let result; try { result = await this.db - .prepare( - `DELETE FROM developer_claims WHERE id = ? AND claimant_id = ? AND status = 'pending'` - ) - .bind(claimId, claimantId) - .run(); + .delete(developerClaims) + .where( + and( + eq(developerClaims.id, claimId), + eq(developerClaims.claimantId, claimantId), + eq(developerClaims.status, "pending") + ) + ); } catch (error) { return databaseError("cancelClaim", error); } - if (!result.success) { - return databaseError( - "cancelClaim", - new Error(result.error || "Database query failed") - ); - } - if (!result.meta?.changes) { return { data: null, @@ -1269,58 +1150,43 @@ export class DevelopersDatabase { async listMyClaims( claimantId: string ): Promise> { - let result; + let rows; try { - result = await this.db - .prepare( - "SELECT * FROM developer_claims WHERE claimant_id = ? ORDER BY created_at DESC" - ) - .bind(claimantId) - .all>(); + rows = await this.db + .select() + .from(developerClaims) + .where(eq(developerClaims.claimantId, claimantId)) + .orderBy(desc(developerClaims.createdAt)); } catch (error) { return databaseError("listMyClaims", error); } - if (!result.success) { - return databaseError( - "listMyClaims", - new Error(result.error || "Database query failed") - ); - } - - return { - data: (result.results ?? []).map(parseClaimRow), - error: null - }; + return { data: rows.map(parseClaimRow), error: null }; } async listPendingClaims(): Promise> { - let result; + let rows; try { - result = await this.db - .prepare( - `SELECT c.*, d.name AS developer_name, d.type AS developer_type - FROM developer_claims c JOIN developers d ON d.id = c.developer_id - WHERE c.status = 'pending' ORDER BY c.created_at ASC` - ) - .all>(); + rows = await this.db + .select({ + claim: developerClaims, + developerName: developers.name, + developerType: developers.type + }) + .from(developerClaims) + .innerJoin(developers, eq(developers.id, developerClaims.developerId)) + .where(eq(developerClaims.status, "pending")) + .orderBy(asc(developerClaims.createdAt)); } catch (error) { return databaseError("listPendingClaims", error); } - if (!result.success) { - return databaseError( - "listPendingClaims", - new Error(result.error || "Database query failed") - ); - } - return { - data: (result.results ?? []).map((row) => ({ - ...parseClaimRow(row), - developer_name: row.developer_name as string, + data: rows.map((row) => ({ + ...parseClaimRow(row.claim), + developer_name: row.developerName, developer_type: - row.developer_type as PendingDeveloperClaim["developer_type"] + row.developerType as PendingDeveloperClaim["developer_type"] })), error: null }; @@ -1332,12 +1198,9 @@ export class DevelopersDatabase { private async revertClaimToPending(id: string): Promise { try { await this.db - .prepare( - `UPDATE developer_claims SET status = 'pending', reviewer_id = NULL, reviewed_at = NULL - WHERE id = ?` - ) - .bind(id) - .run(); + .update(developerClaims) + .set({ status: "pending", reviewerId: null, reviewedAt: null }) + .where(eq(developerClaims.id, id)); } catch { // best-effort only } @@ -1366,35 +1229,28 @@ export class DevelopersDatabase { }; } - if (!this.db.batch) { - return databaseError( - "approveClaim", - new Error("Database adapter does not support batch operations") - ); - } - try { - const developer = await this.db - .prepare("SELECT owner_user_id FROM developers WHERE id = ?") - .bind(claim.developer_id) - .first<{ owner_user_id: string | null }>(); + const [developer] = await this.db + .select({ ownerUserId: developers.ownerUserId }) + .from(developers) + .where(eq(developers.id, claim.developer_id)); if (!developer) { return { data: null, error: { message: "Developer not found", code: "NOT_FOUND" } }; } - if (developer.owner_user_id !== null) { + if (developer.ownerUserId !== null) { return { data: null, error: { message: "This profile is already owned", code: "CONFLICT" } }; } - const conflict = await this.db - .prepare("SELECT 1 FROM developers WHERE owner_user_id = ?") - .bind(claim.claimant_id) - .first(); + const [conflict] = await this.db + .select({ one: sql`1` }) + .from(developers) + .where(eq(developers.ownerUserId, claim.claimant_id)); if (conflict) { return { data: null, @@ -1413,22 +1269,22 @@ export class DevelopersDatabase { let claimResult; try { claimResult = await this.db - .prepare( - `UPDATE developer_claims SET status = 'approved', reviewer_id = ?, reviewed_at = CURRENT_TIMESTAMP - WHERE id = ? AND status = 'pending'` - ) - .bind(reviewerId, claimId) - .run(); + .update(developerClaims) + .set({ + status: "approved", + reviewerId, + reviewedAt: sql`CURRENT_TIMESTAMP` + }) + .where( + and( + eq(developerClaims.id, claimId), + eq(developerClaims.status, "pending") + ) + ); } catch (error) { return databaseError("approveClaim", error); } - if (!claimResult.success) { - return databaseError( - "approveClaim", - new Error(claimResult.error || "Database query failed") - ); - } if (!claimResult.meta?.changes) { return { data: null, @@ -1444,49 +1300,45 @@ export class DevelopersDatabase { // caught below. rejectOthersStmt is gated on that same win via // `changes() = 1`, so competing claims are only auto-rejected once // ownership has actually moved, not whenever this batch merely runs. + // Both batched via the raw D1 client (see toD1Statement / upsertOwn's + // historyStmt comment) - rejectOthersStmt's changes()=1 gate needs it + // regardless, and mixing a query builder item with a raw one in the + // same batch hits the same drizzle-orm bug either way. let results; try { const developerStmt = this.db - .prepare( - `UPDATE developers - SET owner_user_id = ?, - ownership_epoch = ownership_epoch + 1, - content_revision = content_revision + 1, - approved_at = NULL, approved_revision = NULL, approved_by = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND owner_user_id IS NULL` - ) - .bind(claim.claimant_id, claim.developer_id); - const rejectOthersStmt = this.db - .prepare( - `UPDATE developer_claims SET status = 'rejected', reviewer_id = ?, reviewed_at = CURRENT_TIMESTAMP, - review_note = 'Another claim on this profile was approved' - WHERE changes() = 1 AND developer_id = ? AND status = 'pending' AND id != ?` - ) - .bind(reviewerId, claim.developer_id, claimId); - - results = (await this.db.batch([ - developerStmt, + .update(developers) + .set({ + ownerUserId: claim.claimant_id, + ownershipEpoch: sql`ownership_epoch + 1`, + contentRevision: sql`content_revision + 1`, + approvedAt: null, + approvedRevision: null, + approvedBy: null, + updatedAt: sql`CURRENT_TIMESTAMP` + }) + .where( + and( + eq(developers.id, claim.developer_id), + isNull(developers.ownerUserId) + ) + ); + const rejectOthersStmt = toD1Statement(this.db.$client, { + sql: `UPDATE developer_claims SET status = 'rejected', reviewer_id = ?, reviewed_at = CURRENT_TIMESTAMP, + review_note = 'Another claim on this profile was approved' + WHERE changes() = 1 AND developer_id = ? AND status = 'pending' AND id != ?`, + params: [reviewerId, claim.developer_id, claimId] + }); + + results = await this.db.$client.batch([ + toD1Statement(this.db.$client, developerStmt.toSQL()), rejectOthersStmt - ])) as Array<{ - success: boolean; - error?: string; - meta?: { changes?: number }; - }>; + ]); } catch (error) { await this.revertClaimToPending(claimId); return databaseError("approveClaim", error); } - const failed = results.find((r) => !r.success); - if (failed) { - await this.revertClaimToPending(claimId); - return databaseError( - "approveClaim", - new Error(failed.error || "Database write failed") - ); - } - const [developerResult] = results; if (!developerResult.meta?.changes) { await this.revertClaimToPending(claimId); @@ -1510,23 +1362,23 @@ export class DevelopersDatabase { let result; try { result = await this.db - .prepare( - `UPDATE developer_claims SET status = 'rejected', reviewer_id = ?, review_note = ?, reviewed_at = CURRENT_TIMESTAMP - WHERE id = ? AND status = 'pending'` - ) - .bind(reviewerId, reviewNote, claimId) - .run(); + .update(developerClaims) + .set({ + status: "rejected", + reviewerId, + reviewNote, + reviewedAt: sql`CURRENT_TIMESTAMP` + }) + .where( + and( + eq(developerClaims.id, claimId), + eq(developerClaims.status, "pending") + ) + ); } catch (error) { return databaseError("rejectClaim", error); } - if (!result.success) { - return databaseError( - "rejectClaim", - new Error(result.error || "Database query failed") - ); - } - if (!result.meta?.changes) { return { data: null, diff --git a/src/services/extensions/v2/errors.ts b/src/services/extensions/v2/errors.ts index d3bba19..b1e8814 100644 --- a/src/services/extensions/v2/errors.ts +++ b/src/services/extensions/v2/errors.ts @@ -1,6 +1,22 @@ import { DatabaseResult } from "../../../lib/interfaces"; import { logError } from "../../../lib/logger"; +// Drizzle wraps the real D1 driver error in a DrizzleError whose own +// .message is a generic "Failed to run the query ''" - the actual +// SQLite/D1 message (e.g. "UNIQUE constraint failed: ...") lives in +// .cause, not .message. Regex-matching driver error text (see +// isOwnerConflict/isPendingClaimConflict/isPendingTargetConflict) needs the +// whole chain, not just the outermost message. +export function errorMessageChain(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + while (current instanceof Error) { + parts.push(current.message); + current = (current as Error & { cause?: unknown }).cause; + } + return parts.join(" "); +} + // Logs the real error server-side and returns a generic message to the // caller — DB exception text can leak schema/backend details otherwise. export function databaseError( @@ -8,7 +24,7 @@ export function databaseError( error: unknown ): DatabaseResult { logError("extensions-v2", context, { - error: error instanceof Error ? error.message : String(error) + error: error instanceof Error ? errorMessageChain(error) : String(error) }); return { data: null, diff --git a/src/services/extensions/v2/extensions-database.ts b/src/services/extensions/v2/extensions-database.ts index fa521bf..a1a02e0 100644 --- a/src/services/extensions/v2/extensions-database.ts +++ b/src/services/extensions/v2/extensions-database.ts @@ -1,4 +1,7 @@ -import { DatabaseResult, IDatabase } from "../../../lib/interfaces"; +import { and, eq, sql } from "drizzle-orm"; +import { DatabaseResult } from "../../../lib/interfaces"; +import { ExtensionsDb } from "../../../lib/db"; +import { extensions, developers } from "./db/schema"; import { databaseError } from "./errors"; import { Extension, @@ -9,21 +12,52 @@ import { } from "./interfaces"; // LEFT JOIN so an extension whose developer row is missing (author_id -// pointing nowhere) still lists — author_id isn't a hard FK (see -// 0001_add_v2_tables.sql). COALESCE keeps developer_id non-null in that -// case: e.author_id is itself NOT NULL, so the id half of the embedded -// developer is never lost even when every other field falls back to a -// default in parseExtensionRow below. -const SELECT_EXTENSIONS = ` - SELECT e.id, e.type, e.name, e.description, e.releases, e.website, e.license, - e.icon_url, e.readme, e.source, e.version, e.download_url, - COALESCE(d.id, e.author_id) AS developer_id, - d.type AS developer_type, d.name AS developer_name, - d.url AS developer_url, - d.avatar_url AS developer_avatar_url, d.approved_at AS developer_approved_at - FROM extensions e - LEFT JOIN developers d ON e.author_id = d.id -`; +// pointing nowhere) still lists - author_id isn't a hard FK (see +// 0001_add_v2_tables.sql). COALESCE keeps developerId non-null in that +// case: extensions.authorId is itself NOT NULL, so the id half of the +// embedded developer is never lost even when every other field falls back +// to a default in parseExtensionRow below. +const EXTENSION_COLUMNS = { + id: extensions.id, + type: extensions.type, + name: extensions.name, + description: extensions.description, + releases: extensions.releases, + website: extensions.website, + license: extensions.license, + iconUrl: extensions.iconUrl, + readme: extensions.readme, + source: extensions.source, + version: extensions.version, + downloadUrl: extensions.downloadUrl, + developerId: sql`COALESCE(${developers.id}, ${extensions.authorId})`, + developerType: developers.type, + developerName: developers.name, + developerUrl: developers.url, + developerAvatarUrl: developers.avatarUrl, + developerApprovedAt: developers.approvedAt +}; + +interface ExtensionRow { + id: string; + type: string; + name: string; + description: string; + releases: string; + website: string; + license: string; + iconUrl: string | null; + readme: string; + source: string; + version: string; + downloadUrl: string; + developerId: string; + developerType: string | null; + developerName: string | null; + developerUrl: string | null; + developerAvatarUrl: string | null; + developerApprovedAt: string | null; +} export interface ExtensionListFilters { type?: string; @@ -31,63 +65,45 @@ export interface ExtensionListFilters { } export class ExtensionsDatabase { - private db: IDatabase; - - constructor(db: IDatabase) { - this.db = db; - } + constructor(private db: ExtensionsDb) {} async list( filters: ExtensionListFilters = {} ): Promise> { - const conditions: string[] = []; - const params: unknown[] = []; - if (filters.type) { - conditions.push("e.type = ?"); - params.push(filters.type); - } - if (filters.developerId) { - conditions.push("e.author_id = ?"); - params.push(filters.developerId); - } - const query = conditions.length - ? `${SELECT_EXTENSIONS} WHERE ${conditions.join(" AND ")}` - : SELECT_EXTENSIONS; + const conditions = []; + if (filters.type) conditions.push(eq(extensions.type, filters.type)); + if (filters.developerId) + conditions.push(eq(extensions.authorId, filters.developerId)); - let result; + let rows: ExtensionRow[]; try { - result = await this.db - .prepare(query) - .bind(...params) - .all>(); + const query = this.db + .select(EXTENSION_COLUMNS) + .from(extensions) + .leftJoin(developers, eq(extensions.authorId, developers.id)); + rows = conditions.length + ? await query.where(and(...conditions)) + : await query; } catch (error) { return databaseError("list", error); } - if (!result.success) { - return databaseError( - "list", - new Error(result.error || "Database query failed") - ); - } - - return { - data: (result.results ?? []).map(parseExtensionRow), - error: null - }; + return { data: rows.map(parseExtensionRow), error: null }; } async getById(id: string): Promise> { - let row; + let rows: ExtensionRow[]; try { - row = await this.db - .prepare(`${SELECT_EXTENSIONS} WHERE LOWER(e.id) = LOWER(?)`) - .bind(id) - .first>(); + rows = await this.db + .select(EXTENSION_COLUMNS) + .from(extensions) + .leftJoin(developers, eq(extensions.authorId, developers.id)) + .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); } catch (error) { return databaseError("getById", error); } + const row = rows[0]; if (!row) { return { data: null, @@ -113,34 +129,28 @@ function parseJSON(value: unknown, fallback: T): T { return value !== undefined && value !== null ? (value as T) : fallback; } -function parseExtensionRow(row: Record): Extension { +function parseExtensionRow(row: ExtensionRow): Extension { const releases = parseJSON(row.releases, []); return { - id: row.id as string, + id: row.id, type: row.type as Extension["type"], - name: row.name as string, - description: row.description as string, + name: row.name, + description: row.description, releases: sortReleasesDescending(releases), - website: row.website as string, + website: row.website, license: parseJSON(row.license, { name: "" }), - icon_url: typeof row.icon_url === "string" ? row.icon_url : undefined, - readme: row.readme as string, + icon_url: row.iconUrl ?? undefined, + readme: row.readme, source: parseJSON(row.source, { type: "custom", repo: "" }), - version: row.version as string, - download_url: row.download_url as string, + version: row.version, + download_url: row.downloadUrl, developer: { - id: row.developer_id as string, - type: (row.developer_type as "user" | "organization") ?? "user", - name: (row.developer_name as string) ?? "", - URL: - typeof row.developer_url === "string" ? row.developer_url : undefined, - avatar_url: - typeof row.developer_avatar_url === "string" - ? row.developer_avatar_url - : undefined, - approved: - row.developer_approved_at !== null && - row.developer_approved_at !== undefined + id: row.developerId, + type: (row.developerType as "user" | "organization") ?? "user", + name: row.developerName ?? "", + URL: row.developerUrl ?? undefined, + avatar_url: row.developerAvatarUrl ?? undefined, + approved: row.developerApprovedAt !== null } }; } diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 26f7484..d4ece47 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -5,6 +5,7 @@ import { MiddlewareHandler } from "hono"; import { Scalar } from "@scalar/hono-api-reference"; import { getPlatform } from "../../../lib/middleware"; import { getAuth, requireAuth } from "../../../lib/auth"; +import { getExtensionsDb } from "../../../lib/db"; import { ClaimNoteSchema, DeveloperClaimSchema, @@ -68,8 +69,7 @@ function statusFromErrorCode(code?: string): 404 | 409 | 500 { function requireModerator(): MiddlewareHandler { return async (c, next) => { const auth = getAuth(c); - const platform = getPlatform(c); - const users = new UsersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data: isModerator, error } = await users.isModerator(auth.userId); if (error) { @@ -117,8 +117,7 @@ const listExtensionsRoute = createRoute({ extensionsV2.openapi(listExtensionsRoute, async (c) => { const { type, developer_id } = c.req.valid("query"); - const platform = getPlatform(c); - const db = new ExtensionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.list({ type, developerId: developer_id }); if (error || !data) { @@ -166,8 +165,7 @@ const getExtensionRoute = createRoute({ extensionsV2.openapi(getExtensionRoute, async (c) => { const { id } = c.req.valid("param"); - const platform = getPlatform(c); - const db = new ExtensionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.getById(id); if (error || !data) { @@ -236,8 +234,7 @@ const createSubmissionRoute = createRoute({ extensionsV2.openapi(createSubmissionRoute, async (c) => { const auth = getAuth(c); const payload = c.req.valid("json"); - const platform = getPlatform(c); - const db = new SubmissionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const ownership = await db.resolveOwnership(payload, auth.userId); if (ownership.error || !ownership.data) { @@ -315,8 +312,7 @@ const mineRoute = createRoute({ extensionsV2.openapi(mineRoute, async (c) => { const auth = getAuth(c); const { limit, cursor } = c.req.valid("query"); - const platform = getPlatform(c); - const db = new SubmissionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.listBySubmitter(auth.userId, limit, cursor); if (error || !data) { @@ -384,8 +380,7 @@ const queueRoute = createRoute({ }); extensionsV2.openapi(queueRoute, async (c) => { - const platform = getPlatform(c); - const db = new SubmissionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { status, limit, cursor } = c.req.valid("query"); @@ -475,8 +470,7 @@ extensionsV2.openapi(approveRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); const { review_note } = c.req.valid("json"); - const platform = getPlatform(c); - const db = new SubmissionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.approve(id, auth.userId, review_note); if (error || !data) { @@ -550,8 +544,7 @@ extensionsV2.openapi(rejectRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); const { review_note } = c.req.valid("json"); - const platform = getPlatform(c); - const db = new SubmissionsDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.reject(id, auth.userId, review_note); if (error || !data) { @@ -620,7 +613,7 @@ extensionsV2.openapi(upsertOwnDeveloperRoute, async (c) => { const auth = getAuth(c); const body = c.req.valid("json"); const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.upsertOwn( auth.userId, @@ -688,8 +681,7 @@ const deleteOwnDeveloperRoute = createRoute({ extensionsV2.openapi(deleteOwnDeveloperRoute, async (c) => { const auth = getAuth(c); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.deleteOwn(auth.userId); if (error || !data) { @@ -769,7 +761,7 @@ extensionsV2.openapi(claimDeveloperRoute, async (c) => { const { id } = c.req.valid("param"); const { note } = c.req.valid("json"); const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.claim( id, @@ -833,8 +825,7 @@ const cancelClaimRoute = createRoute({ extensionsV2.openapi(cancelClaimRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.cancelClaim(id, auth.userId); if (error || !data) { @@ -882,8 +873,7 @@ const myClaimsRoute = createRoute({ extensionsV2.openapi(myClaimsRoute, async (c) => { const auth = getAuth(c); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.listMyClaims(auth.userId); if (error || !data) { @@ -933,8 +923,7 @@ const pendingClaimsRoute = createRoute({ }); extensionsV2.openapi(pendingClaimsRoute, async (c) => { - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.listPendingClaims(); if (error || !data) { @@ -1001,8 +990,7 @@ const approveClaimRoute = createRoute({ extensionsV2.openapi(approveClaimRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.approveClaim(id, auth.userId); if (error || !data) { @@ -1069,8 +1057,7 @@ extensionsV2.openapi(rejectClaimRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); const { review_note } = c.req.valid("json"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.rejectClaim(id, auth.userId, review_note); if (error || !data) { @@ -1133,8 +1120,7 @@ const initiateTransferRoute = createRoute({ extensionsV2.openapi(initiateTransferRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.initiateTransfer(id, auth.userId); if (error || !data) { @@ -1197,8 +1183,7 @@ const revokeTransferRoute = createRoute({ extensionsV2.openapi(revokeTransferRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.revokeTransfer(id, auth.userId); if (error || !data) { @@ -1263,8 +1248,7 @@ const acceptTransferRoute = createRoute({ extensionsV2.openapi(acceptTransferRoute, async (c) => { const auth = getAuth(c); const { token } = c.req.valid("json"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.acceptTransfer(token, auth.userId); if (error || !data) { @@ -1315,8 +1299,7 @@ const allDevelopersRoute = createRoute({ }); extensionsV2.openapi(allDevelopersRoute, async (c) => { - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.listAll(); if (error || !data) { @@ -1366,8 +1349,7 @@ const unapprovedDevelopersRoute = createRoute({ }); extensionsV2.openapi(unapprovedDevelopersRoute, async (c) => { - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.listUnapproved(); if (error || !data) { @@ -1423,8 +1405,7 @@ const getDeveloperRoute = createRoute({ extensionsV2.openapi(getDeveloperRoute, async (c) => { const { id } = c.req.valid("param"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.getById(id); if (error || !data) { @@ -1498,8 +1479,7 @@ extensionsV2.openapi(approveDeveloperRoute, async (c) => { const auth = getAuth(c); const { id } = c.req.valid("param"); const { expected_revision } = c.req.valid("json"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.approve(id, expected_revision, auth.userId); if (error || !data) { @@ -1556,8 +1536,7 @@ const developerHistoryRoute = createRoute({ extensionsV2.openapi(developerHistoryRoute, async (c) => { const { id } = c.req.valid("param"); - const platform = getPlatform(c); - const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS")); + const db = new DevelopersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.listHistory(id); if (error || !data) { @@ -1594,7 +1573,7 @@ extensionsV2.get( agent: { disabled: true }, - documentDownloadType: 'none', + documentDownloadType: "none", hideClientButton: true, hideModels: true, hiddenClients: { @@ -1607,20 +1586,20 @@ extensionsV2.get( java: true, js: ["axios", "jquery", "ofetch"], kotlin: true, - node: ['axios', 'ofetch', 'undici'], + node: ["axios", "ofetch", "undici"], objc: true, ocaml: true, - php: ['guzzle', 'laravel'], + php: ["guzzle", "laravel"], powershell: true, python: true, r: true, ruby: true, rust: true, - shell: ['httpie'], - swift: true, + shell: ["httpie"], + swift: true }, telemetry: false - }), + }) ); export default extensionsV2; diff --git a/src/services/extensions/v2/submissions-database.ts b/src/services/extensions/v2/submissions-database.ts index 4088f49..cd113c7 100644 --- a/src/services/extensions/v2/submissions-database.ts +++ b/src/services/extensions/v2/submissions-database.ts @@ -1,5 +1,9 @@ -import { DatabaseResult, IDatabase } from "../../../lib/interfaces"; -import { databaseError } from "./errors"; +import { and, asc, desc, eq, gt, lt, or, sql } from "drizzle-orm"; +import { DatabaseResult } from "../../../lib/interfaces"; +import { ExtensionsDb } from "../../../lib/db"; +import { extensionSubmissions, developers, extensions } from "./db/schema"; +import { databaseError, errorMessageChain } from "./errors"; +import { toD1Statement } from "./d1-batch"; import { Submission, SubmissionPayload, SubmissionStatus } from "./interfaces"; interface OwnershipResolution { @@ -29,9 +33,8 @@ interface StoredSubmission extends Submission { const MAX_PENDING_SUBMISSIONS_PER_USER = 10; function isPendingTargetConflict(error: unknown): boolean { - return ( - error instanceof Error && - /UNIQUE constraint failed.*extension_submissions/i.test(error.message) + return /UNIQUE constraint failed.*extension_submissions/i.test( + errorMessageChain(error) ); } @@ -52,32 +55,56 @@ function decodeCursor(cursor: string): [string, string] | null { } } -function parseSubmissionRow(row: Record): StoredSubmission { +interface SubmissionRow { + id: string; + extensionId: string | null; + developerId: string; + submittedBy: string; + status: string; + payload: string; + reviewerId: string | null; + reviewNote: string | null; + createdAt: string; + reviewedAt: string | null; + ownershipEpoch: number; +} + +function parseSubmissionRow(row: SubmissionRow): StoredSubmission { const submission = { - id: row.id as string, - extension_id: (row.extension_id as string | null) ?? null, - developer_id: row.developer_id as string, - submitted_by: row.submitted_by as string, + id: row.id, + extension_id: row.extensionId, + developer_id: row.developerId, + submitted_by: row.submittedBy, status: row.status as SubmissionStatus, - payload: JSON.parse(row.payload as string) as SubmissionPayload, - reviewer_id: (row.reviewer_id as string | null) ?? null, - review_note: (row.review_note as string | null) ?? null, - created_at: row.created_at as string, - reviewed_at: (row.reviewed_at as string | null) ?? null + payload: JSON.parse(row.payload) as SubmissionPayload, + reviewer_id: row.reviewerId, + review_note: row.reviewNote, + created_at: row.createdAt, + reviewed_at: row.reviewedAt } as StoredSubmission; Object.defineProperty(submission, "ownershipEpoch", { - value: Number(row.ownership_epoch ?? 1), + value: Number(row.ownershipEpoch ?? 1), enumerable: false }); return submission; } -export class SubmissionsDatabase { - private db: IDatabase; +const SUBMISSION_COLUMNS = { + id: extensionSubmissions.id, + extensionId: extensionSubmissions.extensionId, + developerId: extensionSubmissions.developerId, + submittedBy: extensionSubmissions.submittedBy, + status: extensionSubmissions.status, + payload: extensionSubmissions.payload, + reviewerId: extensionSubmissions.reviewerId, + reviewNote: extensionSubmissions.reviewNote, + createdAt: extensionSubmissions.createdAt, + reviewedAt: extensionSubmissions.reviewedAt, + ownershipEpoch: extensionSubmissions.ownershipEpoch +}; - constructor(db: IDatabase) { - this.db = db; - } +export class SubmissionsDatabase { + constructor(private db: ExtensionsDb) {} // Edits require owning the extension's current developer; the developer // named in the payload (create, or an edit naming a different developer) @@ -88,27 +115,23 @@ export class SubmissionsDatabase { callerId: string ): Promise> { try { - const existingExtension = await this.db - .prepare( - "SELECT id, author_id FROM extensions WHERE LOWER(id) = LOWER(?)" - ) - .bind(payload.extension.id) - .first<{ id: string; author_id: string }>(); + const [existingExtension] = await this.db + .select({ id: extensions.id, authorId: extensions.authorId }) + .from(extensions) + .where(sql`LOWER(${extensions.id}) = LOWER(${payload.extension.id})`); let extensionId: string | null = null; if (existingExtension) { - const existingDeveloper = await this.db - .prepare( - "SELECT owner_user_id, ownership_epoch FROM developers WHERE id = ?" - ) - .bind(existingExtension.author_id) - .first<{ owner_user_id: string | null; ownership_epoch: number }>(); - - if ( - !existingDeveloper || - existingDeveloper.owner_user_id !== callerId - ) { + const [existingDeveloper] = await this.db + .select({ + ownerUserId: developers.ownerUserId, + ownershipEpoch: developers.ownershipEpoch + }) + .from(developers) + .where(eq(developers.id, existingExtension.authorId)); + + if (!existingDeveloper || existingDeveloper.ownerUserId !== callerId) { return { data: null, error: { @@ -121,14 +144,15 @@ export class SubmissionsDatabase { extensionId = existingExtension.id; } - const payloadDeveloper = await this.db - .prepare( - "SELECT owner_user_id, ownership_epoch FROM developers WHERE id = ?" - ) - .bind(payload.developer.id) - .first<{ owner_user_id: string | null; ownership_epoch: number }>(); + const [payloadDeveloper] = await this.db + .select({ + ownerUserId: developers.ownerUserId, + ownershipEpoch: developers.ownershipEpoch + }) + .from(developers) + .where(eq(developers.id, payload.developer.id)); - if (!payloadDeveloper || payloadDeveloper.owner_user_id !== callerId) { + if (!payloadDeveloper || payloadDeveloper.ownerUserId !== callerId) { return { data: null, error: { @@ -143,7 +167,7 @@ export class SubmissionsDatabase { data: { extensionId, developerId: payload.developer.id, - ownershipEpoch: Number(payloadDeveloper.ownership_epoch ?? 1) + ownershipEpoch: Number(payloadDeveloper.ownershipEpoch ?? 1) }, error: null }; @@ -157,46 +181,27 @@ export class SubmissionsDatabase { let result; try { - result = await this.db - .prepare( - `INSERT INTO extension_submissions + result = await this.db.run(sql` + INSERT INTO ${extensionSubmissions} (id, extension_id, developer_id, submitted_by, status, payload, ownership_epoch, target_key) - SELECT ?, ?, ?, ?, 'pending', ?, d.ownership_epoch, LOWER(?) - FROM developers d - WHERE d.id = ? AND d.owner_user_id = ? AND d.ownership_epoch = ? + SELECT ${id}, ${input.extensionId}, ${input.developerId}, ${input.submittedBy}, 'pending', ${JSON.stringify(input.payload)}, d.ownership_epoch, LOWER(${input.payload.extension.id}) + FROM ${developers} d + WHERE d.id = ${input.developerId} AND d.owner_user_id = ${input.submittedBy} AND d.ownership_epoch = ${input.ownershipEpoch} AND ( - SELECT COUNT(*) FROM extension_submissions - WHERE submitted_by = ? AND status = 'pending' - ) < ? + SELECT COUNT(*) FROM ${extensionSubmissions} + WHERE submitted_by = ${input.submittedBy} AND status = 'pending' + ) < ${MAX_PENDING_SUBMISSIONS_PER_USER} AND ( - (? IS NULL AND NOT EXISTS ( - SELECT 1 FROM extensions WHERE LOWER(id) = LOWER(?) + (${input.extensionId} IS NULL AND NOT EXISTS ( + SELECT 1 FROM ${extensions} WHERE LOWER(id) = LOWER(${input.payload.extension.id}) )) OR - (? IS NOT NULL AND EXISTS ( - SELECT 1 FROM extensions - WHERE id = ? AND author_id = d.id + (${input.extensionId} IS NOT NULL AND EXISTS ( + SELECT 1 FROM ${extensions} + WHERE id = ${input.extensionId} AND author_id = d.id )) - )` - ) - .bind( - id, - input.extensionId, - input.developerId, - input.submittedBy, - JSON.stringify(input.payload), - input.payload.extension.id, - input.developerId, - input.submittedBy, - input.ownershipEpoch, - input.submittedBy, - MAX_PENDING_SUBMISSIONS_PER_USER, - input.extensionId, - input.payload.extension.id, - input.extensionId, - input.extensionId - ) - .run(); + ) + `); } catch (error) { if (isPendingTargetConflict(error)) { return { @@ -210,13 +215,6 @@ export class SubmissionsDatabase { return databaseError("create", error); } - if (!result.success) { - return databaseError( - "create", - new Error(result.error || "Database query failed") - ); - } - if (!result.meta?.changes) { return { data: null, @@ -243,37 +241,35 @@ export class SubmissionsDatabase { error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" } }; } - let result; + + let rows: SubmissionRow[]; try { - const statement = decoded - ? this.db - .prepare( - `SELECT * FROM extension_submissions - WHERE submitted_by = ? - AND (created_at < ? OR (created_at = ? AND id < ?)) - ORDER BY created_at DESC, id DESC LIMIT ?` + const conditions = [eq(extensionSubmissions.submittedBy, userId)]; + if (decoded) { + const [createdAt, cursorId] = decoded; + conditions.push( + or( + lt(extensionSubmissions.createdAt, createdAt), + and( + eq(extensionSubmissions.createdAt, createdAt), + lt(extensionSubmissions.id, cursorId) ) - .bind(userId, decoded[0], decoded[0], decoded[1], limit + 1) - : this.db - .prepare( - `SELECT * FROM extension_submissions - WHERE submitted_by = ? - ORDER BY created_at DESC, id DESC LIMIT ?` - ) - .bind(userId, limit + 1); - result = await statement.all>(); + )! + ); + } + rows = await this.db + .select(SUBMISSION_COLUMNS) + .from(extensionSubmissions) + .where(and(...conditions)) + .orderBy( + desc(extensionSubmissions.createdAt), + desc(extensionSubmissions.id) + ) + .limit(limit + 1); } catch (error) { return databaseError("listBySubmitter", error); } - if (!result.success) { - return databaseError( - "listBySubmitter", - new Error(result.error || "Database query failed") - ); - } - - const rows = result.results ?? []; const hasMore = rows.length > limit; const items = rows.slice(0, limit).map(parseSubmissionRow); const last = items.at(-1); @@ -300,37 +296,35 @@ export class SubmissionsDatabase { error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" } }; } - let result; + + let rows: SubmissionRow[]; try { - const statement = decoded - ? this.db - .prepare( - `SELECT * FROM extension_submissions - WHERE status = ? - AND (created_at > ? OR (created_at = ? AND id > ?)) - ORDER BY created_at ASC, id ASC LIMIT ?` - ) - .bind(status, decoded[0], decoded[0], decoded[1], limit + 1) - : this.db - .prepare( - `SELECT * FROM extension_submissions - WHERE status = ? - ORDER BY created_at ASC, id ASC LIMIT ?` + const conditions = [eq(extensionSubmissions.status, status)]; + if (decoded) { + const [createdAt, cursorId] = decoded; + conditions.push( + or( + gt(extensionSubmissions.createdAt, createdAt), + and( + eq(extensionSubmissions.createdAt, createdAt), + gt(extensionSubmissions.id, cursorId) ) - .bind(status, limit + 1); - result = await statement.all>(); + )! + ); + } + rows = await this.db + .select(SUBMISSION_COLUMNS) + .from(extensionSubmissions) + .where(and(...conditions)) + .orderBy( + asc(extensionSubmissions.createdAt), + asc(extensionSubmissions.id) + ) + .limit(limit + 1); } catch (error) { return databaseError("listQueue", error); } - if (!result.success) { - return databaseError( - "listQueue", - new Error(result.error || "Database query failed") - ); - } - - const rows = result.results ?? []; const hasMore = rows.length > limit; const items = rows.slice(0, limit).map(parseSubmissionRow); const last = items.at(-1); @@ -346,12 +340,12 @@ export class SubmissionsDatabase { } async getById(id: string): Promise> { - let row; + let row: SubmissionRow | undefined; try { - row = await this.db - .prepare("SELECT * FROM extension_submissions WHERE id = ?") - .bind(id) - .first>(); + [row] = await this.db + .select(SUBMISSION_COLUMNS) + .from(extensionSubmissions) + .where(eq(extensionSubmissions.id, id)); } catch (error) { return databaseError("getById", error); } @@ -400,24 +394,23 @@ export class SubmissionsDatabase { let result; try { result = await this.db - .prepare( - `UPDATE extension_submissions - SET status = 'rejected', reviewer_id = ?, review_note = ?, reviewed_at = CURRENT_TIMESTAMP - WHERE id = ? AND status = 'pending'` - ) - .bind(reviewerId, reviewNote, id) - .run(); + .update(extensionSubmissions) + .set({ + status: "rejected", + reviewerId, + reviewNote, + reviewedAt: sql`CURRENT_TIMESTAMP` + }) + .where( + and( + eq(extensionSubmissions.id, id), + eq(extensionSubmissions.status, "pending") + ) + ); } catch (error) { return databaseError("reject", error); } - if (!result.success) { - return databaseError( - "reject", - new Error(result.error || "Database query failed") - ); - } - if (!result.meta?.changes) { return this.explainNoOpTransition(id); } @@ -449,80 +442,77 @@ export class SubmissionsDatabase { }; } - if (!this.db.batch) { - return databaseError( - "approve", - new Error("Database adapter does not support batch operations") - ); - } - const { developer, extension } = submission.payload; const extensionId = submission.extension_id ?? extension.id; + // Kept as raw sql via the raw D1 client (see toD1Statement) rather than + // the query builder: D1's batch() executes these three statements as + // one transaction, and the developer/extension statements are + // deliberately gated on `changes() = 1` from the immediately-preceding + // statement (SQLite's per-connection changes() function) so a race or + // ownership/target change caught by the first statement's WHERE also + // blocks the other two - a guarantee that's easy to silently lose by + // rewriting this as three independent query-builder calls. Also works + // around a drizzle-orm 0.45.2 bug where db.run(sql) with bound params + // can't be used inside db.batch() at all (confirmed via an isolated + // repro against real D1). let results; try { - // D1 executes a batch as one transaction. The first statement binds - // the moderation transition to the submitter's captured ownership - // epoch; transfer and approval therefore cannot interleave. - const claimStmt = this.db - .prepare( - `UPDATE extension_submissions - SET status = 'approved', reviewer_id = ?, review_note = ?, reviewed_at = CURRENT_TIMESTAMP - WHERE id = ? AND status = 'pending' - AND EXISTS ( - SELECT 1 FROM developers d - WHERE d.id = extension_submissions.developer_id - AND d.owner_user_id = extension_submissions.submitted_by - AND d.ownership_epoch = extension_submissions.ownership_epoch - ) - AND ( - (extension_id IS NULL AND NOT EXISTS ( - SELECT 1 FROM extensions e - WHERE LOWER(e.id) = LOWER(?) - )) - OR - (extension_id IS NOT NULL AND EXISTS ( - SELECT 1 FROM extensions e - WHERE e.id = extension_submissions.extension_id - AND e.author_id = extension_submissions.developer_id - )) - )` - ) - .bind(reviewerId, reviewNote ?? null, id, extension.id); - - const developerStmt = this.db - .prepare( - `UPDATE developers - SET type = ?, name = ?, url = ?, - content_revision = content_revision + 1, - approved_at = NULL, approved_revision = NULL, approved_by = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE changes() = 1 AND id = ? AND owner_user_id = ? - AND ownership_epoch = ?` - ) - .bind( + const claimStmt = toD1Statement(this.db.$client, { + sql: `UPDATE extension_submissions + SET status = 'approved', reviewer_id = ?, review_note = ?, reviewed_at = CURRENT_TIMESTAMP + WHERE id = ? AND status = 'pending' + AND EXISTS ( + SELECT 1 FROM developers d + WHERE d.id = extension_submissions.developer_id + AND d.owner_user_id = extension_submissions.submitted_by + AND d.ownership_epoch = extension_submissions.ownership_epoch + ) + AND ( + (extension_id IS NULL AND NOT EXISTS ( + SELECT 1 FROM extensions e + WHERE LOWER(e.id) = LOWER(?) + )) + OR + (extension_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM extensions e + WHERE e.id = extension_submissions.extension_id + AND e.author_id = extension_submissions.developer_id + )) + )`, + params: [reviewerId, reviewNote ?? null, id, extension.id] + }); + + const developerStmt = toD1Statement(this.db.$client, { + sql: `UPDATE developers + SET type = ?, name = ?, url = ?, + content_revision = content_revision + 1, + approved_at = NULL, approved_revision = NULL, approved_by = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE changes() = 1 AND id = ? AND owner_user_id = ? + AND ownership_epoch = ?`, + params: [ developer.type, developer.name, developer.URL ?? null, developer.id, submission.submitted_by, submission.ownershipEpoch - ); - - const extensionStmt = this.db - .prepare( - `INSERT INTO extensions (id, type, author_id, name, description, releases, website, license, icon_url, readme, source, version, download_url) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - WHERE changes() = 1 - ON CONFLICT(id) DO UPDATE SET - type = excluded.type, author_id = excluded.author_id, name = excluded.name, - description = excluded.description, releases = excluded.releases, - website = excluded.website, license = excluded.license, - icon_url = excluded.icon_url, readme = excluded.readme, - source = excluded.source, version = excluded.version, - download_url = excluded.download_url` - ) - .bind( + ] + }); + + const extensionStmt = toD1Statement(this.db.$client, { + sql: `INSERT INTO extensions (id, type, author_id, name, description, releases, website, license, icon_url, readme, source, version, download_url) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE changes() = 1 + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, author_id = excluded.author_id, name = excluded.name, + description = excluded.description, releases = excluded.releases, + website = excluded.website, license = excluded.license, + icon_url = excluded.icon_url, readme = excluded.readme, + source = excluded.source, version = excluded.version, + download_url = excluded.download_url`, + params: [ extensionId, extension.type, developer.id, @@ -536,29 +526,18 @@ export class SubmissionsDatabase { JSON.stringify(extension.source), extension.version, extension.download_url - ); + ] + }); - results = (await this.db.batch([ + results = await this.db.$client.batch([ claimStmt, developerStmt, extensionStmt - ])) as Array<{ - success: boolean; - error?: string; - meta?: { changes?: number }; - }>; + ]); } catch (error) { return databaseError("approve", error); } - const failed = results.find((r) => !r.success); - if (failed) { - return databaseError( - "approve", - new Error(failed.error || "Database write failed") - ); - } - if (!results[0]?.meta?.changes) { return { data: null, diff --git a/src/services/extensions/v2/users-database.ts b/src/services/extensions/v2/users-database.ts index 4daca28..8e9f076 100644 --- a/src/services/extensions/v2/users-database.ts +++ b/src/services/extensions/v2/users-database.ts @@ -1,4 +1,7 @@ -import { DatabaseResult, IDatabase } from "../../../lib/interfaces"; +import { eq } from "drizzle-orm"; +import { DatabaseResult } from "../../../lib/interfaces"; +import { ExtensionsDb } from "../../../lib/db"; +import { users } from "./db/external-tables"; import { databaseError } from "./errors"; export type GithubIdentity = { @@ -12,19 +15,15 @@ export type GithubIdentity = { // columns used here: users.id (TEXT, = auth `sub` claim), users.is_moderator // (INTEGER 0/1), users.github_login (TEXT), users.github_orgs (TEXT, JSON array). export class UsersDatabase { - private db: IDatabase; - - constructor(db: IDatabase) { - this.db = db; - } + constructor(private db: ExtensionsDb) {} async isModerator(userId: string): Promise> { try { - const row = await this.db - .prepare("SELECT is_moderator FROM users WHERE id = ?") - .bind(userId) - .first<{ is_moderator: number }>(); - return { data: row?.is_moderator === 1, error: null }; + const [row] = await this.db + .select({ isModerator: users.isModerator }) + .from(users) + .where(eq(users.id, userId)); + return { data: row?.isModerator === 1, error: null }; } catch (error) { return databaseError("isModerator", error); } @@ -35,17 +34,22 @@ export class UsersDatabase { // only populated once the claimant has signed in via GitHub since the // auth service started requesting the read:org scope; absent columns/rows // resolve to "no linked identity" rather than throwing. - async getGithubIdentity(userId: string): Promise> { + async getGithubIdentity( + userId: string + ): Promise> { try { - const row = await this.db - .prepare("SELECT github_login, github_orgs FROM users WHERE id = ?") - .bind(userId) - .first<{ github_login: string | null; github_orgs: string | null }>(); + const [row] = await this.db + .select({ + githubLogin: users.githubLogin, + githubOrgs: users.githubOrgs + }) + .from(users) + .where(eq(users.id, userId)); let githubOrgs: string[] = []; - if (row?.github_orgs) { + if (row?.githubOrgs) { try { - const parsed = JSON.parse(row.github_orgs); + const parsed = JSON.parse(row.githubOrgs); if (Array.isArray(parsed)) githubOrgs = parsed; } catch { // Malformed JSON is treated the same as "no orgs recorded" — @@ -54,7 +58,7 @@ export class UsersDatabase { } return { - data: { githubLogin: row?.github_login ?? null, githubOrgs }, + data: { githubLogin: row?.githubLogin ?? null, githubOrgs }, error: null }; } catch (error) { diff --git a/test/app/index.test.ts b/test/app/index.test.ts index 738b5da..110d83e 100644 --- a/test/app/index.test.ts +++ b/test/app/index.test.ts @@ -4,14 +4,13 @@ * @license AGPL-3.0 */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import { env } from "cloudflare:workers"; import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; import app from "../../src/app/index"; -import { mockD1Database } from "../utils/d1-mock"; import { ApiResponse, CentralAlertsResponse, @@ -20,6 +19,7 @@ import { VersionInfo, VersionsResponse } from "../utils/test-types"; +import { applyTestMigrations } from "../utils/apply-migrations"; vi.mock("@octokit/request", () => { const endpoint = { DEFAULTS: {} }; @@ -67,6 +67,8 @@ const mockComposerJson = { }; describe("FOSSBilling API Worker - Main App", () => { + beforeAll(applyTestMigrations); + beforeEach(async () => { // Clear KV cache before each test await env.CACHE_KV.delete("gh-fossbilling-releases"); @@ -76,9 +78,6 @@ describe("FOSSBilling API Worker - Main App", () => { const testUpdateToken = "test-update-token-12345"; await env.AUTH_KV.put("UPDATE_TOKEN", testUpdateToken); - // Mock D1 database binding for central alerts - env.DB_CENTRAL_ALERTS = mockD1Database; - // Reset all mocks vi.clearAllMocks(); diff --git a/test/env.d.ts b/test/env.d.ts index a276b6b..b64b219 100644 --- a/test/env.d.ts +++ b/test/env.d.ts @@ -3,3 +3,17 @@ declare module "cloudflare:test" { type ProvidedEnv = CloudflareBindings; } + +// Test-only bindings injected by vitest.config.ts (via readD1Migrations) for +// test/apply-migrations.ts to apply - never part of the real deployed +// Worker's bindings, so deliberately not in worker-configuration.d.ts. +// `env` from "cloudflare:test" is typed as Cloudflare.Env (not ProvidedEnv), +// hence augmenting this namespace directly rather than the module above. +declare namespace Cloudflare { + interface Env { + TEST_MIGRATIONS_EXTENSIONS: import("cloudflare:test").D1Migration[]; + TEST_MIGRATIONS_CENTRAL_ALERTS: import("cloudflare:test").D1Migration[]; + TEST_V1_SCHEMA_SQL: string; + TEST_USERS_STUB_SQL: string; + } +} diff --git a/test/integration/app.test.ts b/test/integration/app.test.ts index df11cef..2f945fc 100644 --- a/test/integration/app.test.ts +++ b/test/integration/app.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import { createExecutionContext, waitOnExecutionContext @@ -7,7 +7,7 @@ import { env } from "cloudflare:workers"; import app from "../../src/app/index"; import { mockGitHubReleases, mockComposerJson } from "../mocks/github-releases"; import { setupGitHubApiMock } from "../utils/mock-helpers"; -import { mockD1Database } from "../utils/d1-mock"; +import { applyTestMigrations } from "../utils/apply-migrations"; import { ApiResponse, CentralAlertsResponse, @@ -35,13 +35,13 @@ import { graphql } from "@octokit/graphql"; import { resetUpdateTokenCache } from "../../src/services/versions/v1/index"; describe("FOSSBilling API Worker - Full App Integration", () => { + beforeAll(applyTestMigrations); + beforeEach(async () => { await env.CACHE_KV.delete("gh-fossbilling-releases"); resetUpdateTokenCache(); await env.AUTH_KV.put("UPDATE_TOKEN", "test-update-token-12345"); - env.DB_CENTRAL_ALERTS = mockD1Database; - vi.clearAllMocks(); setupGitHubApiMock( vi.mocked(ghRequest) as MockGitHubRequest, diff --git a/test/lib/adapters/cloudflare/database.test.ts b/test/lib/adapters/cloudflare/database.test.ts deleted file mode 100644 index 48b4f10..0000000 --- a/test/lib/adapters/cloudflare/database.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { CloudflareD1Adapter } from "../../../../src/lib/adapters/cloudflare/database"; -import { IPreparedStatement } from "../../../../src/lib/interfaces"; - -describe("CloudflareD1Adapter", () => { - let mockD1: D1Database; - let mockStatement: D1PreparedStatement; - let adapter: CloudflareD1Adapter; - - beforeEach(() => { - mockStatement = { - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [], success: true }), - first: vi.fn().mockResolvedValue(null), - run: vi.fn().mockResolvedValue({ success: true, meta: {} }) - } as unknown as D1PreparedStatement; - - mockD1 = { - prepare: vi.fn().mockReturnValue(mockStatement), - batch: vi.fn().mockResolvedValue([]) - } as unknown as D1Database; - - adapter = new CloudflareD1Adapter(mockD1); - }); - - describe("prepare", () => { - it("should prepare a statement", () => { - const result = adapter.prepare("SELECT * FROM table"); - - expect(mockD1.prepare).toHaveBeenCalledWith("SELECT * FROM table"); - expect(result).toBeDefined(); - expect(result.all).toBeDefined(); - expect(result.first).toBeDefined(); - expect(result.run).toBeDefined(); - }); - }); - - describe("bind", () => { - it("should bind parameters to statement", async () => { - const stmt = adapter.prepare("SELECT * FROM table WHERE id = ?"); - - stmt.bind(1, 2, 3); - - expect(mockStatement.bind).toHaveBeenCalledWith(1, 2, 3); - }); - - it("should return statement after binding", async () => { - const stmt = adapter.prepare("SELECT * FROM table"); - const boundStmt = stmt.bind("param1"); - - expect(boundStmt).toBe(stmt); - }); - }); - - describe("all", () => { - it("should return all results", async () => { - mockStatement.all = vi.fn().mockResolvedValue({ - results: [{ id: 1 }, { id: 2 }], - success: true - }); - - const stmt = adapter.prepare("SELECT * FROM table"); - const result = await stmt.all(); - - expect(result.results).toEqual([{ id: 1 }, { id: 2 }]); - expect(result.success).toBe(true); - }); - - it("should return empty results array when no results", async () => { - mockStatement.all = vi.fn().mockResolvedValue({ - results: undefined, - success: true - }); - - const stmt = adapter.prepare("SELECT * FROM table"); - const result = await stmt.all(); - - expect(result.results).toBeUndefined(); - }); - }); - - describe("first", () => { - it("should return first result", async () => { - const mockData = { id: 1, name: "test" }; - mockStatement.first = vi.fn().mockResolvedValue(mockData); - - const stmt = adapter.prepare("SELECT * FROM table LIMIT 1"); - const result = await stmt.first(); - - expect(result).toEqual(mockData); - }); - - it("should return null when no results", async () => { - mockStatement.first = vi.fn().mockResolvedValue(null); - - const stmt = adapter.prepare("SELECT * FROM table LIMIT 1"); - const result = await stmt.first(); - - expect(result).toBeNull(); - }); - }); - - describe("run", () => { - it("should execute statement and return result", async () => { - mockStatement.run = vi.fn().mockResolvedValue({ - success: true, - meta: { changes: 1, last_row_id: 123 } - }); - - const stmt = adapter.prepare("INSERT INTO table (name) VALUES (?)"); - const result = await stmt.run(); - - expect(result.success).toBe(true); - expect(result.meta?.changes).toBe(1); - expect(result.meta?.last_row_id).toBe(123); - }); - - it("should return success false on failure", async () => { - const mockError = new Error("Database error"); - mockStatement.run = vi.fn().mockResolvedValue({ - success: false, - error: mockError - }); - - const stmt = adapter.prepare("INSERT INTO table (name) VALUES (?)"); - const result = await stmt.run(); - - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should return success false when error is string", async () => { - mockStatement.run = vi.fn().mockResolvedValue({ - success: false, - error: "SQL error" - }); - - const stmt = adapter.prepare("INSERT INTO table (name) VALUES (?)"); - const result = await stmt.run(); - - expect(result.success).toBe(false); - expect(result.error).toBe("SQL error"); - }); - }); - - describe("batch", () => { - it("should execute multiple statements", async () => { - const stmt1 = adapter.prepare("INSERT INTO table (name) VALUES (?)"); - const stmt2 = adapter.prepare("UPDATE table SET name = ? WHERE id = ?"); - - const results: unknown[] = [{ success: true }, { success: true }]; - mockD1.batch = vi.fn().mockResolvedValue(results); - - const result = await adapter.batch([stmt1, stmt2]); - - expect(mockD1.batch).toHaveBeenCalledWith([ - expect.objectContaining({}), - expect.objectContaining({}) - ]); - expect(result).toEqual(results); - }); - - it("should throw error when statement is not CloudflareD1Statement", async () => { - const stmt1 = adapter.prepare("SELECT * FROM table"); - const invalidStmt = { - bind: vi.fn(), - all: vi.fn(), - first: vi.fn(), - run: vi.fn() - } as IPreparedStatement; - - await expect(adapter.batch([stmt1, invalidStmt])).rejects.toThrow( - "Invalid statement type for D1 batch" - ); - }); - - it("should handle empty batch array", async () => { - const results: unknown[] = []; - mockD1.batch = vi.fn().mockResolvedValue(results); - - const result = await adapter.batch([]); - - expect(mockD1.batch).toHaveBeenCalledWith([]); - expect(result).toEqual([]); - }); - }); -}); diff --git a/test/lib/adapters/node/database.test.ts b/test/lib/adapters/node/database.test.ts deleted file mode 100644 index 540fc55..0000000 --- a/test/lib/adapters/node/database.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { DatabaseSync } from "node:sqlite"; -import { existsSync, unlinkSync } from "node:fs"; -import { - SQLiteAdapter, - createInMemoryDatabase, - createFileDatabase, - createDefaultAdapter -} from "../../../../src/lib/adapters/node/database"; -import { IPreparedStatement } from "../../../../src/lib/interfaces"; - -describe("SQLiteAdapter", () => { - let adapter: SQLiteAdapter; - let db: DatabaseSync; - - beforeEach(() => { - db = new DatabaseSync(":memory:"); - db.exec(` - CREATE TABLE test_table ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - value INTEGER - ) - `); - adapter = new SQLiteAdapter(db); - }); - - afterEach(() => { - db.close(); - }); - - describe("prepare", () => { - it("should prepare a statement", () => { - const stmt = adapter.prepare("SELECT * FROM test_table"); - - expect(stmt).toBeDefined(); - expect(typeof stmt.bind).toBe("function"); - expect(typeof stmt.all).toBe("function"); - expect(typeof stmt.first).toBe("function"); - expect(typeof stmt.run).toBe("function"); - }); - }); - - describe("bind", () => { - it("should bind parameters to statement", async () => { - const stmt = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - - stmt.bind("test", 123); - - const result = await stmt.run(); - expect(result.success).toBe(true); - }); - - it("should bind multiple parameters", async () => { - const stmt = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - - stmt.bind("name1", 1); - await stmt.run(); - - stmt.bind("name2", 2); - await stmt.run(); - - stmt.bind("name3", 3); - const result = await stmt.run(); - - expect(result.success).toBe(true); - }); - - it("should return statement after binding", async () => { - const stmt = adapter.prepare("SELECT * FROM test_table WHERE id = ?"); - const boundStmt = stmt.bind(1); - - expect(boundStmt).toBe(stmt); - }); - }); - - describe("all", () => { - beforeEach(() => { - const stmt = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - stmt.bind("test1", 100).run(); - stmt.bind("test2", 200).run(); - stmt.bind("test3", 300).run(); - }); - - it("should return all results", async () => { - const stmt = adapter.prepare("SELECT * FROM test_table ORDER BY id"); - const result = await stmt.all(); - - expect(result.success).toBe(true); - expect(result.results).toHaveLength(3); - expect(result.results?.[0]).toMatchObject({ name: "test1", value: 100 }); - expect(result.results?.[1]).toMatchObject({ name: "test2", value: 200 }); - expect(result.results?.[2]).toMatchObject({ name: "test3", value: 300 }); - }); - - it("should return empty array when no results", async () => { - const stmt = adapter.prepare("SELECT * FROM test_table WHERE id = 999"); - const result = await stmt.all(); - - expect(result.success).toBe(true); - expect(result.results).toHaveLength(0); - }); - }); - - describe("first", () => { - beforeEach(() => { - const stmt = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - stmt.bind("first", 1).run(); - stmt.bind("second", 2).run(); - }); - - it("should return first result", async () => { - const stmt = adapter.prepare( - "SELECT * FROM test_table ORDER BY id LIMIT 1" - ); - const result = await stmt.first(); - - expect(result).toMatchObject({ name: "first", value: 1 }); - }); - - it("should return null when no results", async () => { - const stmt = adapter.prepare("SELECT * FROM test_table WHERE id = 999"); - const result = await stmt.first(); - - expect(result).toBeNull(); - }); - }); - - describe("run", () => { - it("should execute INSERT statement", async () => { - const stmt = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - const result = await stmt.bind("test", 123).run(); - - expect(result.success).toBe(true); - expect(result.meta?.changes).toBe(1); - expect(result.meta?.last_row_id).toBeGreaterThanOrEqual(1); - }); - - it("should execute UPDATE statement", async () => { - const stmt = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - await stmt.bind("original", 1).run(); - - const updateStmt = adapter.prepare( - "UPDATE test_table SET value = ? WHERE name = ?" - ); - const result = await updateStmt.bind(999, "original").run(); - - expect(result.success).toBe(true); - expect(result.meta?.changes).toBe(1); - }); - - it("should execute DELETE statement", async () => { - const insertStmt = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - await insertStmt.bind("to-delete", 1).run(); - - const deleteStmt = adapter.prepare( - "DELETE FROM test_table WHERE name = ?" - ); - const result = await deleteStmt.bind("to-delete").run(); - - expect(result.success).toBe(true); - expect(result.meta?.changes).toBe(1); - }); - }); - - describe("batch", () => { - it("should execute multiple statements in transaction", async () => { - const stmt1 = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - const stmt2 = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - const stmt3 = adapter.prepare( - "INSERT INTO test_table (name, value) VALUES (?, ?)" - ); - - const results: unknown[] = await adapter.batch([ - stmt1.bind("batch1", 1), - stmt2.bind("batch2", 2), - stmt3.bind("batch3", 3) - ]); - - expect(results).toHaveLength(3); - expect(results[0]).toHaveProperty("success"); - expect(results[1]).toHaveProperty("success"); - expect(results[2]).toHaveProperty("success"); - }); - - it("should handle empty batch array", async () => { - const results = await adapter.batch([]); - - expect(results).toEqual([]); - }); - - it("should throw error for invalid statement type", async () => { - const validStmt = adapter.prepare( - "INSERT INTO test_table (name) VALUES (?)" - ); - const invalidStmt = { - bind: vi.fn(), - all: vi.fn(), - first: vi.fn(), - run: vi.fn() - } as IPreparedStatement; - - await expect( - adapter.batch([validStmt.bind("test"), invalidStmt]) - ).rejects.toThrow("Invalid statement type for SQLite batch"); - }); - }); -}); - -describe("Factory Functions", () => { - const testDbPath = "/tmp/test-sqlite.db"; - - afterEach(() => { - try { - if (existsSync(testDbPath)) { - unlinkSync(testDbPath); - } - } catch { - // Ignore cleanup errors - } - }); - - describe("createInMemoryDatabase", () => { - it("should create an in-memory database", () => { - const db = createInMemoryDatabase(); - - expect(db).toBeInstanceOf(DatabaseSync); - }); - - it("should allow operations on in-memory database", () => { - const db = createInMemoryDatabase(); - db.exec("CREATE TABLE test (id INTEGER)"); - db.exec("INSERT INTO test VALUES (1)"); - - const stmt = db.prepare("SELECT * FROM test"); - const result = stmt.all(); - - expect(result).toHaveLength(1); - }); - }); - - describe("createFileDatabase", () => { - it("should create a file-based database", () => { - const db = createFileDatabase(testDbPath); - - expect(db).toBeInstanceOf(DatabaseSync); - expect(db).toBeDefined(); - db.close(); - }); - - it("should persist data to file", () => { - const db1 = createFileDatabase(testDbPath); - db1.exec("CREATE TABLE test (id INTEGER)"); - db1.exec("INSERT INTO test VALUES (1)"); - db1.close(); - - const db2 = createFileDatabase(testDbPath); - const stmt = db2.prepare("SELECT * FROM test"); - const result = stmt.all(); - - expect(result).toHaveLength(1); - db2.close(); - }); - }); - - describe("createDefaultAdapter", () => { - it("should create adapter with in-memory database", () => { - const adapter = createDefaultAdapter(); - - expect(adapter).toBeInstanceOf(SQLiteAdapter); - }); - - it("should allow operations on created adapter", async () => { - const adapter = createDefaultAdapter(); - adapter.prepare("CREATE TABLE test (id INTEGER)").run(); - - const stmt = adapter.prepare("SELECT * FROM test"); - const result = await stmt.all(); - - expect(result.success).toBe(true); - }); - }); -}); diff --git a/test/lib/adapters/node/index.test.ts b/test/lib/adapters/node/index.test.ts index a502381..91caca0 100644 --- a/test/lib/adapters/node/index.test.ts +++ b/test/lib/adapters/node/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createNodeBindings, SQLiteAdapter } from "../../../../src/lib/adapters/node/index"; +import { createNodeBindings } from "../../../../src/lib/adapters/node/index"; describe("createNodeBindings", () => { it("should create bindings with default paths", () => { @@ -7,7 +7,6 @@ describe("createNodeBindings", () => { expect(bindings).toBeDefined(); expect(bindings.caches).toBeDefined(); - expect(bindings.databases.DB_EXTENSIONS).toBeInstanceOf(SQLiteAdapter); expect(bindings.environment).toBeDefined(); }); diff --git a/test/lib/auth/bearer-assertion.test.ts b/test/lib/auth/bearer-assertion.test.ts index 0211e65..060ec5f 100644 --- a/test/lib/auth/bearer-assertion.test.ts +++ b/test/lib/auth/bearer-assertion.test.ts @@ -7,9 +7,6 @@ const SECRET = "test-secret"; function platformWithSecret(secret: string | undefined): PlatformContext { return { - getDatabase: () => { - throw new Error("not implemented"); - }, getCache: () => { throw new Error("not implemented"); }, diff --git a/test/lib/context.test.ts b/test/lib/context.test.ts index 0737dc6..27e7424 100644 --- a/test/lib/context.test.ts +++ b/test/lib/context.test.ts @@ -1,26 +1,10 @@ import { describe, it, expect } from "vitest"; import { Context } from "hono"; import { createPlatformContext } from "../../src/lib/context"; -import { - IPlatformBindings, - IPreparedStatement -} from "../../src/lib/interfaces"; +import { IPlatformBindings } from "../../src/lib/interfaces"; describe("createPlatformContext", () => { const mockBindings: IPlatformBindings = { - databases: { - testDb: { - prepare: () => { - const mockStmt: IPreparedStatement = { - bind: () => mockStmt, - all: async () => ({ results: [], success: true }), - first: async () => null, - run: async () => ({ success: true }) - }; - return mockStmt; - } - } - }, caches: { testCache: { get: async () => null, @@ -44,29 +28,11 @@ describe("createPlatformContext", () => { it("should create a platform context with all required methods", () => { const context = createPlatformContext(mockHonoContext, mockBindings); - expect(context).toHaveProperty("getDatabase"); expect(context).toHaveProperty("getCache"); expect(context).toHaveProperty("getEnv"); expect(context).toHaveProperty("raw"); }); - describe("getDatabase", () => { - it("should return the requested database binding", () => { - const context = createPlatformContext(mockHonoContext, mockBindings); - const db = context.getDatabase("testDb"); - - expect(db).toBe(mockBindings.databases.testDb); - }); - - it("should throw when database binding not found", () => { - const context = createPlatformContext(mockHonoContext, mockBindings); - - expect(() => context.getDatabase("nonexistent")).toThrow( - "Database binding 'nonexistent' not found" - ); - }); - }); - describe("getCache", () => { it("should return the requested cache binding", () => { const context = createPlatformContext(mockHonoContext, mockBindings); diff --git a/test/lib/middleware.test.ts b/test/lib/middleware.test.ts index 7f06778..3b157c0 100644 --- a/test/lib/middleware.test.ts +++ b/test/lib/middleware.test.ts @@ -2,10 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { Hono, Context } from "hono"; import { platformMiddleware, getPlatform } from "../../src/lib/middleware"; import { createPlatformContext } from "../../src/lib/context"; -import { - IPlatformBindings, - IPreparedStatement -} from "../../src/lib/interfaces"; +import { IPlatformBindings } from "../../src/lib/interfaces"; describe("Middleware", () => { let app: Hono; @@ -15,19 +12,6 @@ describe("Middleware", () => { app = new Hono(); mockBindings = { - databases: { - testDb: { - prepare: () => { - const mockStmt: IPreparedStatement = { - bind: () => mockStmt, - all: async () => ({ results: [], success: true }), - first: async () => null, - run: async () => ({ success: true }) - }; - return mockStmt; - } - } - }, caches: { testCache: { get: async () => null, @@ -57,21 +41,6 @@ describe("Middleware", () => { expect(data.hasPlatform).toBe(true); }); - it("should allow access to database through platform context", async () => { - app.use("*", platformMiddleware(mockBindings)); - - app.get("/test", (c) => { - const platform = c.get("platform"); - const db = platform.getDatabase("testDb"); - return c.json({ hasDatabase: !!db }); - }); - - const response = await app.request("/test"); - const data = (await response.json()) as { hasDatabase: boolean }; - - expect(data.hasDatabase).toBe(true); - }); - it("should allow access to cache through platform context", async () => { app.use("*", platformMiddleware(mockBindings)); @@ -164,7 +133,7 @@ describe("Middleware", () => { const platform = getPlatform(mockHonoContext); expect(platform).toBeDefined(); - expect(platform.getDatabase("testDb")).toBeDefined(); + expect(platform.getCache("testCache")).toBeDefined(); }); it("should throw error when platform context not found", () => { @@ -227,7 +196,7 @@ describe("Middleware", () => { app.get("/test1", (c) => { const platform = c.get("platform"); - return c.json({ contextType: typeof platform.getDatabase }); + return c.json({ contextType: typeof platform.getEnv }); }); app.get("/test2", (c) => { diff --git a/test/mocks/README.md b/test/mocks/README.md index cd8e3c5..03cd3f3 100644 --- a/test/mocks/README.md +++ b/test/mocks/README.md @@ -1,12 +1,11 @@ # Test Mocks -This directory contains mock implementations and mock data used across the test suite. +This directory contains mock data used across the test suite. ## Contents -- `mock-adapters.ts` - Mock implementations for database, cache, and environment adapters - `github-releases.ts` - Mock GitHub API release data ## Usage -Mock adapters are used in unit tests to simulate Cloudflare Workers bindings (D1, KV, environment variables) without requiring actual Cloudflare infrastructure. +Database tests run against a real local D1 (see `@cloudflare/vitest-pool-workers` and `test/utils/apply-migrations.ts`) rather than a mock adapter - see `test/services/extensions/v2/db-fixtures.ts` for seed/read helpers and `test/services/extensions/v2/db-interceptor.ts` for the handful of tests that need to inject a fault or a mid-request race. diff --git a/test/mocks/mock-adapters.ts b/test/mocks/mock-adapters.ts deleted file mode 100644 index ee29edb..0000000 --- a/test/mocks/mock-adapters.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { - IDatabase, - IPreparedStatement, - ICache, - IEnvironment -} from "../../src/lib/interfaces"; - -export class MockDatabaseAdapter implements IDatabase { - private data = new Map(); - - constructor(initialData?: Record) { - if (initialData) { - Object.entries(initialData).forEach(([table, rows]) => { - this.data.set(table, rows); - }); - } - } - - prepare(query: string): IPreparedStatement { - return new MockPreparedStatement(query, this.data); - } - - async batch(statements: IPreparedStatement[]): Promise { - const results = []; - for (const stmt of statements) { - results.push(await stmt.run()); - } - return results; - } - - setTableData(table: string, rows: unknown[]): void { - this.data.set(table, rows); - } - - getTableData(table: string): unknown[] { - return this.data.get(table) || []; - } - - clearTable(table: string): void { - this.data.delete(table); - } - - clearAll(): void { - this.data.clear(); - } -} - -class MockPreparedStatement implements IPreparedStatement { - private params: unknown[] = []; - - constructor( - private query: string, - private data: Map - ) {} - - bind(...params: unknown[]): IPreparedStatement { - this.params = params; - return this; - } - - async all(): Promise<{ - results?: T[]; - success: boolean; - error?: string; - }> { - const fromMatch = this.query.match(/FROM\s+(\w+)/i); - const tableName = fromMatch ? fromMatch[1] : "central_alerts"; - const results = this.data.get(tableName) || []; - return { results: results as T[], success: true }; - } - - async first(): Promise { - const result = await this.all(); - return result.results?.[0] || null; - } - - async run(): Promise<{ - success: boolean; - error?: string; - meta?: { - changes?: number; - last_row_id?: number; - [key: string]: unknown; - }; - }> { - return { - success: true, - meta: { changes: 1 } - }; - } -} - -export class MockCacheAdapter implements ICache { - private store = new Map(); - - async get(key: string): Promise { - const entry = this.store.get(key); - if (!entry) return null; - - if (entry.expiry && entry.expiry < Date.now()) { - this.store.delete(key); - return null; - } - - return entry.value; - } - - async put( - key: string, - value: string, - options?: { expirationTtl?: number } - ): Promise { - let expiry: number | undefined; - - if (options?.expirationTtl) { - expiry = Date.now() + options.expirationTtl * 1000; - } - - this.store.set(key, { value, expiry }); - } - - async delete(key: string): Promise { - this.store.delete(key); - } - - clear(): void { - this.store.clear(); - } - - has(key: string): boolean { - return this.store.has(key); - } - - size(): number { - return this.store.size; - } -} - -export class MockEnvironmentAdapter implements IEnvironment { - constructor(private vars: Record = {}) {} - - get(key: string): string | undefined { - return this.vars[key]; - } - - has(key: string): boolean { - return key in this.vars; - } - - set(key: string, value: string): void { - this.vars[key] = value; - } - - delete(key: string): void { - delete this.vars[key]; - } - - clear(): void { - this.vars = {}; - } - - getAll(): Record { - return { ...this.vars }; - } -} diff --git a/test/services/central-alerts/v1/database.test.ts b/test/services/central-alerts/v1/database.test.ts index 0f48a84..c3719bf 100644 --- a/test/services/central-alerts/v1/database.test.ts +++ b/test/services/central-alerts/v1/database.test.ts @@ -1,260 +1,33 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { env } from "cloudflare:workers"; import { CentralAlertsDatabase } from "../../../../src/services/central-alerts/v1/database"; - -// Mock D1 Database for testing -class MockD1Database { - private alerts: Record[] = []; - private buttons: Record[] = []; - - constructor() { - // Initialize with test data using JSON buttons approach - this.alerts = [ - { - id: "1", - title: "Test Alert", - message: "This is a test alert", - type: "info", - dismissible: false, - min_fossbilling_version: "0.0.0", - max_fossbilling_version: "1.0.0", - include_preview_branch: false, - buttons: - '[{"text":"Test Button","link":"https://example.com","type":"info"}]', - datetime: "2023-01-01T00:00:00Z", - created_at: "2023-01-01T00:00:00Z", - updated_at: "2023-01-01T00:00:00Z" - } - ]; - } - - prepare(query: string) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const mockDb = this; - const result = { - bind(...params: unknown[]) { - return { - all() { - // Simulate getting all alerts (no WHERE clause) - if ( - query.includes("FROM central_alerts") && - (query.includes("ORDER BY datetime DESC") || - query.includes('ORDER BY "datetime" DESC')) && - !query.includes("WHERE") - ) { - return { success: true, results: mockDb.alerts }; - } - - // Simulate getting alerts by version - if ( - query.includes("min_fossbilling_version") && - query.includes("max_fossbilling_version") - ) { - const version = params[0] as string; - const filteredAlerts = mockDb.alerts.filter((alert) => { - // Check if it's the universal alert (applies to all versions) - if ( - alert.min_fossbilling_version === "0.0.0" && - alert.max_fossbilling_version === "9999.9999.9999" - ) { - return true; - } - // For version comparison, we need to check if version is within the alert's range - return ( - version >= (alert.min_fossbilling_version as string) && - version <= (alert.max_fossbilling_version as string) - ); - }); - - return { success: true, results: filteredAlerts }; - } - - return { success: true, results: [] }; - }, - - first() { - // Simulate getting a specific alert by ID - if (query.includes("WHERE id = ?")) { - const id = params[0] as string; - return mockDb.alerts.find((a) => a.id === id) || null; - } - - return null; - }, - - run() { - // Simulate INSERT operations for alerts - if (query.includes("INSERT INTO central_alerts")) { - const newAlert = { - id: params[0], - title: params[1], - message: params[2], - type: params[3], - dismissible: params[4], - min_fossbilling_version: params[5], - max_fossbilling_version: params[6], - include_preview_branch: params[7], - datetime: params[8], - buttons: params[9] || "[]", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }; - - mockDb.alerts.push(newAlert); - return { - success: true, - meta: { - changes: 1 - } - }; - } - - // Simulate UPDATE operations - if (query.includes("UPDATE central_alerts")) { - const id = params[params.length - 1] as string; - const alertIndex = mockDb.alerts.findIndex((a) => a.id === id); - - if (alertIndex !== -1) { - // Update fields based on the query - const alert = mockDb.alerts[alertIndex]; - - // Parse the update fields from the query - const updatedAlert = { ...alert }; - - if (query.includes("title = ?")) { - const titleIndex = params.findIndex( - (p, i) => i < params.length - 1 && p !== id - ); - if (titleIndex !== -1) { - updatedAlert.title = params[titleIndex]; - } - } - - if (query.includes("type = ?")) { - const typeIndex = params.findIndex( - (p, i) => - i < params.length - 1 && - p !== id && - p !== updatedAlert.title - ); - if (typeIndex !== -1) { - updatedAlert.type = params[typeIndex]; - } - } - - updatedAlert.updated_at = new Date().toISOString(); - mockDb.alerts[alertIndex] = updatedAlert; - return { - success: true, - meta: { - changes: 1 - } - }; - } - - return { - success: true, - meta: { - changes: 0 - } - }; - } - - // Simulate DELETE operations - if (query.includes("DELETE FROM central_alerts")) { - const id = params[0] as string; - const initialLength = mockDb.alerts.length; - mockDb.alerts = mockDb.alerts.filter((a) => a.id !== id); - - const changes = initialLength - mockDb.alerts.length; - return { - success: true, - meta: { - changes - } - }; - } - - // Simulate DELETE operations for buttons - if (query.includes("DELETE FROM central_alert_buttons")) { - const alertId = params[0] as string; - const initialLength = mockDb.buttons.length; - mockDb.buttons = mockDb.buttons.filter( - (b) => b.alert_id !== alertId - ); - - return { - success: true, - meta: { - changes: initialLength - mockDb.buttons.length - } - }; - } - - return { - success: true, - meta: { - changes: 0 - } - }; - } - }; - }, - // Also support all() directly on prepare result (no bind) - all() { - // Simulate getting all alerts - if ( - query.includes("FROM central_alerts") && - (query.includes("ORDER BY datetime DESC") || - query.includes('ORDER BY "datetime" DESC')) - ) { - return { success: true, results: mockDb.alerts }; - } - - // Simulate getting alerts by version - if ( - query.includes("WHERE") && - query.includes("min_fossbilling_version") && - query.includes("max_fossbilling_version") - ) { - const version = "0.5.0"; // Default for direct call - const filteredAlerts = mockDb.alerts.filter((alert) => { - return ( - (alert.min_fossbilling_version === "0.0.0" && - alert.max_fossbilling_version === "9999.9999.9999") || - (version >= (alert.min_fossbilling_version as string) && - version <= (alert.max_fossbilling_version as string)) - ); - }); - - return { success: true, results: filteredAlerts }; - } - - return { success: true, results: [] }; - } - }; - - return result; - } - - batch(statements: unknown[]) { - return Promise.all( - statements.map((stmt) => { - if (stmt && typeof stmt === "object" && "bind" in stmt) { - return (stmt as D1PreparedStatement).bind().run(); - } - return (stmt as D1PreparedStatement).run(); - }) - ); - } -} +import { getCentralAlertsDb } from "../../../../src/lib/db"; +import { centralAlerts } from "../../../../src/services/central-alerts/v1/db/schema"; +import { applyTestMigrations } from "../../../utils/apply-migrations"; describe("CentralAlertsDatabase", () => { let db: CentralAlertsDatabase; - let mockD1: MockD1Database; - beforeAll(() => { - mockD1 = new MockD1Database(); - db = new CentralAlertsDatabase(mockD1 as never); + beforeAll(applyTestMigrations); + + beforeEach(async () => { + const drizzleDb = getCentralAlertsDb(env.DB_CENTRAL_ALERTS); + await drizzleDb.delete(centralAlerts); + await drizzleDb.insert(centralAlerts).values({ + id: "1", + title: "Test Alert", + message: "This is a test alert", + type: "info", + dismissible: false, + minFossbillingVersion: "0.0.0", + maxFossbillingVersion: "1.0.0", + includePreviewBranch: false, + buttons: + '[{"text":"Test Button","link":"https://example.com","type":"info"}]', + datetime: "2023-01-01T00:00:00Z" + }); + + db = new CentralAlertsDatabase(drizzleDb); }); describe("getAllAlerts", () => { diff --git a/test/services/central-alerts/v1/index.test.ts b/test/services/central-alerts/v1/index.test.ts index df0d872..5ed1dd2 100644 --- a/test/services/central-alerts/v1/index.test.ts +++ b/test/services/central-alerts/v1/index.test.ts @@ -1,18 +1,19 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeAll } from "vitest"; import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; import { env } from "cloudflare:workers"; import app from "../../../../src/app"; -import { mockD1Database } from "../../../utils/d1-mock"; import type { CentralAlertsResponse } from "../../../utils/test-types"; +import { applyTestMigrations } from "../../../utils/apply-migrations"; +// No fixture-insertion setup needed beyond migrations: migrations already +// seed exactly the row these tests assert on (see +// src/services/central-alerts/v1/db/migrations/0001_seed_initial_alert.sql) +// against the real local D1. describe("Central Alerts API v1", () => { - beforeEach(() => { - // Mock the D1 database binding - env.DB_CENTRAL_ALERTS = mockD1Database; - }); + beforeAll(applyTestMigrations); describe("GET /list", () => { it("should return list of central alerts", async () => { diff --git a/test/services/extensions/v1/index.test.ts b/test/services/extensions/v1/index.test.ts index b4e0452..0ab022d 100644 --- a/test/services/extensions/v1/index.test.ts +++ b/test/services/extensions/v1/index.test.ts @@ -1,38 +1,22 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; import { env } from "cloudflare:workers"; import app from "../../../../src/app"; +import { getExtensionsDb } from "../../../../src/lib/db"; +import { + developers, + extensions +} from "../../../../src/services/extensions/v2/db/schema"; +import { applyTestMigrations } from "../../../utils/apply-migrations"; -type ExtensionRow = { - id: string; - type: string; - author_id: string; - author_type: string; - author_name: string; - author_url: string | null; - name: string; - description: string; - releases: string; - website: string; - license: string; - icon_url?: string; - readme: string; - source: string; - version: string; - download_url: string; -}; - -const testExtensions: ExtensionRow[] = [ +const testExtensionRows = [ { id: "Example", - type: "mod", - author_id: "fossbilling", - author_type: "organization", - author_name: "fossbilling", - author_url: "https://fossbilling.org", + type: "mod" as const, + authorId: "fossbilling", name: "Example Module", description: "An example module for developers.", releases: JSON.stringify([ @@ -56,21 +40,25 @@ const testExtensions: ExtensionRow[] = [ } ]), website: "https://fossbilling.org", - license: JSON.stringify({ name: "Apache 2.0", URL: "https://www.apache.org/licenses/LICENSE-2.0" }), - icon_url: "https://raw.githubusercontent.com/FOSSBilling/example-module/main/src/icon.svg", + license: JSON.stringify({ + name: "Apache 2.0", + URL: "https://www.apache.org/licenses/LICENSE-2.0" + }), + iconUrl: + "https://raw.githubusercontent.com/FOSSBilling/example-module/main/src/icon.svg", readme: "# Example module\n\nThis is an example module.", - source: JSON.stringify({ type: "github", repo: "FOSSBilling/example-module" }), + source: JSON.stringify({ + type: "github", + repo: "FOSSBilling/example-module" + }), version: "0.0.5", - download_url: + downloadUrl: "https://github.com/FOSSBilling/example-module/releases/download/0.0.5/Example.zip" }, { id: "TestTheme", - type: "theme", - author_id: "fossbilling", - author_type: "organization", - author_name: "fossbilling", - author_url: "https://fossbilling.org", + type: "theme" as const, + authorId: "fossbilling", name: "Test Theme", description: "A test theme.", releases: JSON.stringify([ @@ -86,96 +74,25 @@ const testExtensions: ExtensionRow[] = [ readme: "# Test Theme", source: JSON.stringify({ type: "github", repo: "FOSSBilling/test-theme" }), version: "1.0.0", - download_url: "https://example.com/TestTheme.zip" + downloadUrl: "https://example.com/TestTheme.zip" } ]; -function makeD1Mock(): D1Database { - return { - prepare(query: string): D1PreparedStatement { - let boundParams: unknown[] = []; - - const stmt: D1PreparedStatement = { - bind(...params: unknown[]) { - boundParams = params; - return stmt; - }, - - async all(): Promise> { - let rows = [...testExtensions]; - - if (query.includes("WHERE e.type = ?") && boundParams[0]) { - rows = rows.filter((r) => r.type === boundParams[0]); - } - - return { - success: true, - results: rows as unknown as T[], - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: rows.length, - rows_written: 0, - changed_db: false - } - }; - }, - - async first(): Promise { - if (query.includes("LOWER(e.id) = LOWER(?)") && boundParams[0]) { - const id = String(boundParams[0]).toLowerCase(); - const found = testExtensions.find( - (r) => r.id.toLowerCase() === id - ); - return (found as unknown as T) ?? null; - } - return null; - }, - - raw: (() => { throw new Error("not implemented"); }) as D1PreparedStatement["raw"], - - async run>(): Promise> { - return { - success: true, - results: [], - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; - } - }; - - return stmt; - }, - - dump(): Promise { - throw new Error("not implemented"); - }, - batch(_statements: D1PreparedStatement[]): Promise[]> { - throw new Error("not implemented"); - }, - exec(_query: string): Promise { - throw new Error("not implemented"); - }, - withSession(_constraintOrBookmark?: string): D1DatabaseSession { - throw new Error("not implemented"); - } - }; -} - describe("Extensions API v1", () => { - beforeEach(() => { - env.DB_EXTENSIONS = makeD1Mock(); + beforeAll(applyTestMigrations); + + beforeEach(async () => { + const db = getExtensionsDb(env.DB_EXTENSIONS); + await db.delete(extensions); + await db.delete(developers); + + await db.insert(developers).values({ + id: "fossbilling", + type: "organization", + name: "fossbilling", + url: "https://fossbilling.org" + }); + await db.insert(extensions).values(testExtensionRows); }); describe("GET /list", () => { @@ -185,18 +102,23 @@ describe("Extensions API v1", () => { await waitOnExecutionContext(ctx); expect(res.status).toBe(200); - const data = await res.json() as { result: unknown[] }; + const data = (await res.json()) as { result: unknown[] }; expect(Array.isArray(data.result)).toBe(true); expect(data.result.length).toBe(2); }); it("should filter by type", async () => { const ctx = createExecutionContext(); - const res = await app.request("/extensions/v1/list?type=mod", {}, env, ctx); + const res = await app.request( + "/extensions/v1/list?type=mod", + {}, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(res.status).toBe(200); - const data = await res.json() as { result: Array<{ type: string }> }; + const data = (await res.json()) as { result: Array<{ type: string }> }; expect(data.result.every((e) => e.type === "mod")).toBe(true); expect(data.result.length).toBe(1); }); @@ -214,7 +136,9 @@ describe("Extensions API v1", () => { const res = await app.request("/extensions/v1/list", {}, env, ctx); await waitOnExecutionContext(ctx); - const data = await res.json() as { result: Array<{ id: string; releases: Array<{ tag: string }> }> }; + const data = (await res.json()) as { + result: Array<{ id: string; releases: Array<{ tag: string }> }>; + }; const example = data.result.find((e) => e.id === "Example"); expect(example).toBeTruthy(); expect(example!.releases[0].tag).toBe("0.0.5"); @@ -228,7 +152,9 @@ describe("Extensions API v1", () => { await waitOnExecutionContext(ctx); expect(res.status).toBe(200); - const data = await res.json() as { result: { id: string; name: string } }; + const data = (await res.json()) as { + result: { id: string; name: string }; + }; expect(data.result.id).toBe("Example"); expect(data.result.name).toBe("Example Module"); }); @@ -239,7 +165,7 @@ describe("Extensions API v1", () => { await waitOnExecutionContext(ctx); expect(res.status).toBe(200); - const data = await res.json() as { result: { id: string } }; + const data = (await res.json()) as { result: { id: string } }; expect(data.result.id).toBe("Example"); }); @@ -249,7 +175,7 @@ describe("Extensions API v1", () => { await waitOnExecutionContext(ctx); expect(res.status).toBe(404); - const data = await res.json() as { error: { message: string } }; + const data = (await res.json()) as { error: { message: string } }; expect(data.error.message).toContain("nonexistent"); }); @@ -258,7 +184,9 @@ describe("Extensions API v1", () => { const res = await app.request("/extensions/v1/Example", {}, env, ctx); await waitOnExecutionContext(ctx); - const data = await res.json() as { result: { author: { name: string } } }; + const data = (await res.json()) as { + result: { author: { name: string } }; + }; expect(data.result.author.name).toBe("fossbilling"); }); }); @@ -266,7 +194,12 @@ describe("Extensions API v1", () => { describe("GET /:id/version", () => { it("should return plain text version", async () => { const ctx = createExecutionContext(); - const res = await app.request("/extensions/v1/Example/version", {}, env, ctx); + const res = await app.request( + "/extensions/v1/Example/version", + {}, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(res.status).toBe(200); @@ -277,7 +210,12 @@ describe("Extensions API v1", () => { it("should return 404 for unknown extension", async () => { const ctx = createExecutionContext(); - const res = await app.request("/extensions/v1/nonexistent/version", {}, env, ctx); + const res = await app.request( + "/extensions/v1/nonexistent/version", + {}, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(res.status).toBe(404); @@ -287,7 +225,12 @@ describe("Extensions API v1", () => { describe("GET /:id/badges/:type", () => { it("should return SVG for version badge", async () => { const ctx = createExecutionContext(); - const res = await app.request("/extensions/v1/Example/badges/version", {}, env, ctx); + const res = await app.request( + "/extensions/v1/Example/badges/version", + {}, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(res.status).toBe(200); @@ -299,7 +242,12 @@ describe("Extensions API v1", () => { it("should return SVG for license badge", async () => { const ctx = createExecutionContext(); - const res = await app.request("/extensions/v1/Example/badges/license", {}, env, ctx); + const res = await app.request( + "/extensions/v1/Example/badges/license", + {}, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(res.status).toBe(200); @@ -309,7 +257,12 @@ describe("Extensions API v1", () => { it("should return red SVG for unknown badge type", async () => { const ctx = createExecutionContext(); - const res = await app.request("/extensions/v1/Example/badges/unknown_type", {}, env, ctx); + const res = await app.request( + "/extensions/v1/Example/badges/unknown_type", + {}, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(res.status).toBe(200); diff --git a/test/services/extensions/v2/db-fixtures.ts b/test/services/extensions/v2/db-fixtures.ts new file mode 100644 index 0000000..e191497 --- /dev/null +++ b/test/services/extensions/v2/db-fixtures.ts @@ -0,0 +1,458 @@ +// Raw-SQL test fixtures against the real local D1 (see +// test/utils/apply-migrations.ts) - deliberately not going through Drizzle, +// since these exist to seed/read rows for assertions, not to exercise the +// production query layer. Row shapes are plain snake_case objects matching +// the real column names directly. + +export interface DeveloperRow { + id: string; + type: string; + name: string; + url: string | null; + owner_user_id: string | null; + approved_at: string | null; + created_at: string; + updated_at: string; + avatar_url: string | null; + contact_email: string | null; + ownership_epoch: number; + content_revision: number; + approved_revision: number | null; + approved_by: string | null; + github_org_verified: number | null; + github_verification_note: string | null; +} + +export interface ExtensionRow { + id: string; + type: string; + author_id: string; + name: string; + description: string; + releases: string; + website: string; + license: string; + icon_url: string | null; + readme: string; + source: string; + version: string; + download_url: string; +} + +export interface SubmissionRow { + id: string; + extension_id: string | null; + developer_id: string; + submitted_by: string; + status: string; + payload: string; + reviewer_id: string | null; + review_note: string | null; + created_at: string; + reviewed_at: string | null; + ownership_epoch: number; + target_key: string | null; +} + +export interface DeveloperClaimRow { + id: string; + developer_id: string; + claimant_id: string; + status: string; + note: string | null; + review_note: string | null; + reviewer_id: string | null; + created_at: string; + reviewed_at: string | null; + github_org_verified: number | null; + github_verification_note: string | null; +} + +export interface DeveloperTransferRow { + id: string; + developer_id: string; + token_hash: string; + created_by: string; + created_at: string; + expires_at: string; + accepted_by: string | null; + accepted_at: string | null; + revoked_at: string | null; +} + +export interface DeveloperHistoryRow { + id: string; + developer_id: string; + type: string; + name: string; + url: string | null; + changed_by: string; + changed_at: string; +} + +// developer_history is genuinely append-only in production (see migrations +// 0005/0008's trg_developer_history_no_update/_no_delete triggers) - there +// is no real way to clear it that doesn't briefly drop those triggers, +// which is fine here since this only ever runs against a test-local D1, +// never anywhere the append-only guarantee actually matters. +async function clearDeveloperHistory(db: D1Database): Promise { + await db + .prepare("DROP TRIGGER IF EXISTS trg_developer_history_no_update") + .run(); + await db + .prepare("DROP TRIGGER IF EXISTS trg_developer_history_no_delete") + .run(); + await db.prepare("DELETE FROM developer_history").run(); + await db + .prepare( + `CREATE TRIGGER trg_developer_history_no_update + BEFORE UPDATE ON developer_history + BEGIN + SELECT RAISE(ABORT, 'developer_history is append-only: rows cannot be updated'); + END` + ) + .run(); + await db + .prepare( + `CREATE TRIGGER trg_developer_history_no_delete + BEFORE DELETE ON developer_history + BEGIN + SELECT RAISE(ABORT, 'developer_history is append-only: rows cannot be deleted'); + END` + ) + .run(); +} + +export async function resetExtensionsDb(db: D1Database): Promise { + await clearDeveloperHistory(db); + for (const table of [ + "extension_submissions", + "developer_transfers", + "developer_claims", + "extensions", + "developers", + "users" + ]) { + await db.prepare(`DELETE FROM ${table}`).run(); + } +} + +export async function insertUser( + db: D1Database, + row: { + id: string; + is_moderator?: number; + github_login?: string; + github_orgs?: string; + } +): Promise { + // Upsert rather than a plain INSERT: ensureUser() (called automatically + // by the other insert* helpers below, and by authHeaders() in + // index.test.ts, to satisfy users(id) FKs - e.g. developers.owner_user_id + // - the same way a real caller always already has a users row by the + // time they call this API, since auth happens first) may have already + // created a bare stub row for this id before this richer call runs. + await db + .prepare( + `INSERT INTO users (id, is_moderator, github_login, github_orgs) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + is_moderator = excluded.is_moderator, + github_login = excluded.github_login, + github_orgs = excluded.github_orgs` + ) + .bind( + row.id, + row.is_moderator ?? null, + row.github_login ?? null, + row.github_orgs ?? null + ) + .run(); +} + +// Satisfies a users(id) FK minimally, without clobbering a richer row +// insertUser() may set up separately (before or after this runs). +export async function ensureUser(db: D1Database, id: string): Promise { + await db + .prepare("INSERT OR IGNORE INTO users (id) VALUES (?)") + .bind(id) + .run(); +} + +export async function insertDeveloper( + db: D1Database, + row: Partial & { id: string; type: string; name: string } +): Promise { + if (row.owner_user_id) { + await ensureUser(db, row.owner_user_id); + } + const now = new Date().toISOString(); + await db + .prepare( + `INSERT INTO developers + (id, type, name, url, owner_user_id, approved_at, created_at, updated_at, + avatar_url, contact_email, ownership_epoch, content_revision, + approved_revision, approved_by, github_org_verified, github_verification_note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + row.id, + row.type, + row.name, + row.url ?? null, + row.owner_user_id ?? null, + row.approved_at ?? null, + row.created_at ?? now, + row.updated_at ?? now, + row.avatar_url ?? null, + row.contact_email ?? null, + row.ownership_epoch ?? 1, + row.content_revision ?? 1, + row.approved_revision ?? null, + row.approved_by ?? null, + row.github_org_verified ?? null, + row.github_verification_note ?? null + ) + .run(); +} + +export async function insertExtension( + db: D1Database, + row: ExtensionRow +): Promise { + await db + .prepare( + `INSERT INTO extensions + (id, type, author_id, name, description, releases, website, license, + icon_url, readme, source, version, download_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + row.id, + row.type, + row.author_id, + row.name, + row.description, + row.releases, + row.website, + row.license, + row.icon_url ?? null, + row.readme, + row.source, + row.version, + row.download_url + ) + .run(); +} + +export async function insertSubmission( + db: D1Database, + row: Partial & { + id: string; + developer_id: string; + submitted_by: string; + payload: string; + } +): Promise { + await ensureUser(db, row.submitted_by); + if (row.reviewer_id) { + await ensureUser(db, row.reviewer_id); + } + await db + .prepare( + `INSERT INTO extension_submissions + (id, extension_id, developer_id, submitted_by, status, payload, + reviewer_id, review_note, created_at, reviewed_at, ownership_epoch, target_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + row.id, + row.extension_id ?? null, + row.developer_id, + row.submitted_by, + row.status ?? "pending", + row.payload, + row.reviewer_id ?? null, + row.review_note ?? null, + row.created_at ?? new Date().toISOString(), + row.reviewed_at ?? null, + row.ownership_epoch ?? 1, + row.target_key ?? null + ) + .run(); +} + +export async function insertDeveloperClaim( + db: D1Database, + row: Partial & { + id: string; + developer_id: string; + claimant_id: string; + } +): Promise { + await ensureUser(db, row.claimant_id); + if (row.reviewer_id) { + await ensureUser(db, row.reviewer_id); + } + await db + .prepare( + `INSERT INTO developer_claims + (id, developer_id, claimant_id, status, note, review_note, reviewer_id, + created_at, reviewed_at, github_org_verified, github_verification_note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + row.id, + row.developer_id, + row.claimant_id, + row.status ?? "pending", + row.note ?? null, + row.review_note ?? null, + row.reviewer_id ?? null, + row.created_at ?? new Date().toISOString(), + row.reviewed_at ?? null, + row.github_org_verified ?? null, + row.github_verification_note ?? null + ) + .run(); +} + +export async function getDeveloper( + db: D1Database, + id: string +): Promise { + return db + .prepare("SELECT * FROM developers WHERE id = ?") + .bind(id) + .first(); +} + +export async function hasDeveloper( + db: D1Database, + id: string +): Promise { + return (await getDeveloper(db, id)) !== null; +} + +export async function listDevelopers(db: D1Database): Promise { + const result = await db + .prepare("SELECT * FROM developers") + .all(); + return result.results ?? []; +} + +export async function countExtensions(db: D1Database): Promise { + const row = await db + .prepare("SELECT COUNT(*) AS count FROM extensions") + .first<{ count: number }>(); + return row?.count ?? 0; +} + +export async function getExtension( + db: D1Database, + id: string +): Promise { + return db + .prepare("SELECT * FROM extensions WHERE id = ?") + .bind(id) + .first(); +} + +export async function countSubmissions(db: D1Database): Promise { + const row = await db + .prepare("SELECT COUNT(*) AS count FROM extension_submissions") + .first<{ count: number }>(); + return row?.count ?? 0; +} + +export async function listSubmissions( + db: D1Database +): Promise { + const result = await db + .prepare("SELECT * FROM extension_submissions") + .all(); + return result.results ?? []; +} + +export async function getSubmission( + db: D1Database, + id: string +): Promise { + return db + .prepare("SELECT * FROM extension_submissions WHERE id = ?") + .bind(id) + .first(); +} + +export async function countDeveloperClaims(db: D1Database): Promise { + const row = await db + .prepare("SELECT COUNT(*) AS count FROM developer_claims") + .first<{ count: number }>(); + return row?.count ?? 0; +} + +export async function getDeveloperClaim( + db: D1Database, + id: string +): Promise { + return db + .prepare("SELECT * FROM developer_claims WHERE id = ?") + .bind(id) + .first(); +} + +export async function listDeveloperTransfers( + db: D1Database +): Promise { + const result = await db + .prepare("SELECT * FROM developer_transfers") + .all(); + return result.results ?? []; +} + +export async function listDeveloperClaims( + db: D1Database +): Promise { + const result = await db + .prepare("SELECT * FROM developer_claims") + .all(); + return result.results ?? []; +} + +export async function listDeveloperHistory( + db: D1Database +): Promise { + const result = await db + .prepare("SELECT * FROM developer_history") + .all(); + return result.results ?? []; +} + +// Used by the expired-token test in place of directly mutating a stored +// row object (there's no in-memory row to mutate against real D1). +export async function expireAllDeveloperTransfers( + db: D1Database +): Promise { + await db + .prepare("UPDATE developer_transfers SET expires_at = ?") + .bind("2000-01-01 00:00:00") + .run(); +} + +// Used by the ownership-changes-mid-approval test: the submission's +// ownership_epoch is captured at creation time and only compared later, so +// unlike the deleteOwn/upsertOwn races, this one is a state change, not a +// mid-request timing race - a plain UPDATE before the approve() call +// reproduces it exactly. +export async function bumpDeveloperOwnership( + db: D1Database, + developerId: string, + newOwnerUserId: string +): Promise { + await ensureUser(db, newOwnerUserId); + await db + .prepare( + "UPDATE developers SET owner_user_id = ?, ownership_epoch = ownership_epoch + 1 WHERE id = ?" + ) + .bind(newOwnerUserId, developerId) + .run(); +} diff --git a/test/services/extensions/v2/db-interceptor.ts b/test/services/extensions/v2/db-interceptor.ts new file mode 100644 index 0000000..7d732ef --- /dev/null +++ b/test/services/extensions/v2/db-interceptor.ts @@ -0,0 +1,84 @@ +// A handful of tests need to inject a fault or a concurrent mutation at an +// exact point mid-request (e.g. "ownership changes between this read and +// that guarded write") - something no amount of pre-request DB state setup +// can reproduce, since the race is *inside* a single request's own +// execution. This wraps a real D1Database so a hook fires immediately +// before every statement actually executes (including each statement +// inside a .batch() call, in the order they'd run) - tests match on the +// SQL text to run a side effect or throw, then everything still executes +// for real against the same D1 database. +type Hook = (sql: string) => Promise | void; + +class HookedStatement implements D1PreparedStatement { + constructor( + private readonly real: D1Database, + readonly sqlText: string, + private readonly hook: Hook, + private readonly params: unknown[] = [] + ) {} + + bind(...params: unknown[]): D1PreparedStatement { + return new HookedStatement(this.real, this.sqlText, this.hook, params); + } + + target(): D1PreparedStatement { + return this.real.prepare(this.sqlText).bind(...this.params); + } + + async run>(): Promise> { + await this.hook(this.sqlText); + return this.target().run(); + } + + async all(): Promise> { + await this.hook(this.sqlText); + return this.target().all(); + } + + async first(colName?: string): Promise { + await this.hook(this.sqlText); + return this.target().first(colName as never); + } + + raw: D1PreparedStatement["raw"] = (async ( + ...args: Parameters + ) => { + await this.hook(this.sqlText); + return ( + this.target().raw as ( + ...args: Parameters + ) => ReturnType + )(...args); + }) as D1PreparedStatement["raw"]; +} + +export function wrapD1WithHook(real: D1Database, hook: Hook): D1Database { + return { + prepare(sqlText: string): D1PreparedStatement { + return new HookedStatement(real, sqlText, hook); + }, + async batch( + statements: D1PreparedStatement[] + ): Promise[]> { + // D1's real batch() is one opaque, atomic round-trip - there's no way + // for a client-side wrapper to observe or inject a fault "between" + // individual statements inside it, since they're never executed + // one-at-a-time from the caller's perspective. So every statement's + // hook necessarily fires before the batch is sent, not interleaved + // with its (real, all-or-nothing) execution. That's sufficient for + // faults meant to land before the batch starts (e.g. a concurrent + // mutation racing a guarded write), but it can't simulate "the first + // N statements committed, then the batch failed" - real D1 batches + // don't have partial commits for a hook to reproduce in the first place. + const hooked = statements as unknown as HookedStatement[]; + for (const statement of hooked) { + await hook(statement.sqlText); + } + return real.batch(hooked.map((statement) => statement.target())); + }, + dump: () => real.dump(), + exec: (query: string) => real.exec(query), + withSession: (constraintOrBookmark?: string) => + real.withSession(constraintOrBookmark) + }; +} diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index b7653ff..6dc27e4 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + describe, + it, + expect, + beforeAll, + beforeEach, + afterEach, + vi +} from "vitest"; import { createExecutionContext, waitOnExecutionContext @@ -23,9 +31,34 @@ vi.mock("@octokit/request", () => { import { request as ghRequest } from "@octokit/request"; import app from "../../../../src/app"; -import { createMockD1, createTables, MockTables } from "./mock-db"; import { signAssertion } from "../../../lib/auth/assertion-helper"; import { MockGitHubRequest } from "../../../utils/test-types"; +import { applyTestMigrations } from "../../../utils/apply-migrations"; +import { wrapD1WithHook } from "./db-interceptor"; +import { + resetExtensionsDb, + ensureUser, + insertUser, + insertDeveloper, + insertExtension, + insertSubmission, + insertDeveloperClaim, + getDeveloper, + hasDeveloper, + listDevelopers, + countExtensions, + getExtension, + countSubmissions, + getSubmission, + listSubmissions, + countDeveloperClaims, + getDeveloperClaim, + listDeveloperTransfers, + listDeveloperClaims, + listDeveloperHistory, + expireAllDeveloperTransfers, + bumpDeveloperOwnership +} from "./db-fixtures"; function mockGithubEntityNotFound(): void { (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => { @@ -42,16 +75,32 @@ function mockGithubEntity(type: "User" | "Organization"): void { // Matches the ASSERTION_SIGNING_SECRET binding configured in vitest.config.ts. const SECRET = "test-assertion-signing-secret"; -let tables: MockTables; +// The real D1_EXTENSIONS binding. beforeEach captures it fresh each time and +// afterEach always restores env.DB_EXTENSIONS to this reference, so the +// handful of tests that temporarily wrap it (see db-interceptor.ts) for a +// fault/race injection never leak that wrapper into the next test. +let db: D1Database; -beforeEach(() => { - tables = createTables(); - env.DB_EXTENSIONS = createMockD1(tables); +beforeAll(applyTestMigrations); + +beforeEach(async () => { + db = env.DB_EXTENSIONS; + await resetExtensionsDb(db); vi.clearAllMocks(); mockGithubEntityNotFound(); }); +afterEach(() => { + env.DB_EXTENSIONS = db; +}); + +// In production a caller always already has a `users` row by the time they +// call this API - the shared auth service that mints the assertion is the +// same one that populates it. Real D1 enforces developers.owner_user_id +// (and similar) as a hard FK to users(id), so tests need that precondition +// too; ensureUser() is a no-op if a richer row already exists for this sub. async function authHeaders(sub: string): Promise> { + await ensureUser(db, sub); const token = await signAssertion(SECRET, { sub }); return { Authorization: `Bearer ${token}`, @@ -95,8 +144,8 @@ function samplePayload(overrides?: { // Extension submissions now require the named developer to already exist // (created via PUT /developers/me) and be owned by the caller. -function seedDeveloper(id: string, ownerUserId: string): void { - tables.developers.set(id, { +async function seedDeveloper(id: string, ownerUserId: string): Promise { + await insertDeveloper(db, { id, type: "user", name: "Developer", @@ -105,28 +154,28 @@ function seedDeveloper(id: string, ownerUserId: string): void { }); } -function seedUnownedDeveloper(id: string, name = "Legacy Developer"): void { - tables.developers.set(id, { +async function seedUnownedDeveloper( + id: string, + name = "Legacy Developer" +): Promise { + await insertDeveloper(db, { id, type: "user", name, url: null, - owner_user_id: null, - approved_at: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() + owner_user_id: null }); } -function seedOwnedExtension(): void { - tables.developers.set("owner-developer", { +async function seedOwnedExtension(): Promise { + await insertDeveloper(db, { id: "owner-developer", type: "user", name: "Owner", url: null, owner_user_id: "owner-1" }); - tables.extensions.set("existing-ext", { + await insertExtension(db, { id: "existing-ext", type: "mod", author_id: "owner-developer", @@ -231,7 +280,7 @@ describe("Extensions API v2", () => { }); it("rejects profile fields (avatar_url/contact_email) on a submission's developer", async () => { - seedDeveloper("new-developer", "user-1"); + await seedDeveloper("new-developer", "user-1"); const headers = await authHeaders("user-1"); const payload = samplePayload(); const res = await post("/extensions/v2/submissions", headers, { @@ -243,11 +292,11 @@ describe("Extensions API v2", () => { }); expect(res.status).toBe(422); - expect(tables.extension_submissions.size).toBe(0); + expect(await countSubmissions(db)).toBe(0); }); it("creates a pending submission for a brand-new extension under an existing developer", async () => { - seedDeveloper("new-developer", "user-1"); + await seedDeveloper("new-developer", "user-1"); const headers = await authHeaders("user-1"); const res = await post( "/extensions/v2/submissions", @@ -260,15 +309,15 @@ describe("Extensions API v2", () => { result: { id: string; status: string }; }; expect(data.result.status).toBe("pending"); - expect(tables.extension_submissions.size).toBe(1); + expect(await countSubmissions(db)).toBe(1); - const stored = [...tables.extension_submissions.values()][0]; - expect(stored.extension_id).toBeNull(); - expect(stored.submitted_by).toBe("user-1"); + const stored = await getSubmission(db, data.result.id); + expect(stored?.extension_id).toBeNull(); + expect(stored?.submitted_by).toBe("user-1"); }); it("rejects editing an extension not owned by the caller", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const headers = await authHeaders("intruder"); const res = await post( @@ -281,11 +330,11 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(403); - expect(tables.extension_submissions.size).toBe(0); + expect(await countSubmissions(db)).toBe(0); }); it("allows editing an extension owned by the caller", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const headers = await authHeaders("owner-1"); const res = await post( @@ -298,12 +347,12 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(201); - const stored = [...tables.extension_submissions.values()][0]; + const [stored] = await listSubmissions(db); expect(stored.extension_id).toBe("existing-ext"); }); it("rejects claiming a developer already owned by someone else", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const headers = await authHeaders("intruder"); const res = await post( @@ -328,11 +377,11 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(403); - expect(tables.extension_submissions.size).toBe(0); + expect(await countSubmissions(db)).toBe(0); }); it("bounds payload size and the number of releases", async () => { - seedDeveloper("new-developer", "user-1"); + await seedDeveloper("new-developer", "user-1"); const payload = samplePayload(); const oversized = await post( "/extensions/v2/submissions", @@ -417,7 +466,7 @@ describe("Extensions API v2", () => { it("preserves compatibility with stored slug ids over 100 characters", async () => { const developerId = "d".repeat(120); const extensionId = "e".repeat(120); - seedDeveloper(developerId, "user-1"); + await seedDeveloper(developerId, "user-1"); const res = await post( "/extensions/v2/submissions", @@ -429,7 +478,7 @@ describe("Extensions API v2", () => { }); it("rejects duplicate pending targets and caps each user's backlog", async () => { - seedDeveloper("new-developer", "user-1"); + await seedDeveloper("new-developer", "user-1"); const headers = await authHeaders("user-1"); expect( (await post("/extensions/v2/submissions", headers, samplePayload())) @@ -440,7 +489,7 @@ describe("Extensions API v2", () => { .status ).toBe(409); - seedDeveloper("other-developer", "user-2"); + await seedDeveloper("other-developer", "user-2"); expect( ( await post( @@ -465,14 +514,14 @@ describe("Extensions API v2", () => { samplePayload({ extensionId: "new-ext-over-limit" }) ); expect(overLimit.status).toBe(409); - expect(tables.extension_submissions.size).toBe(10); + expect(await countSubmissions(db)).toBe(10); }); }); describe("GET /submissions/mine", () => { it("returns only the caller's own submissions", async () => { - seedDeveloper("developer-a", "user-1"); - seedDeveloper("developer-b", "user-2"); + await seedDeveloper("developer-a", "user-1"); + await seedDeveloper("developer-b", "user-2"); await post( "/extensions/v2/submissions", await authHeaders("user-1"), @@ -502,7 +551,7 @@ describe("Extensions API v2", () => { }); it("paginates deterministically with an opaque cursor", async () => { - seedDeveloper("new-developer", "user-1"); + await seedDeveloper("new-developer", "user-1"); const headers = await authHeaders("user-1"); for (const extensionId of ["page-a", "page-b", "page-c"]) { expect( @@ -553,8 +602,8 @@ describe("Extensions API v2", () => { }); it("returns pending submissions for a moderator", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); - seedDeveloper("new-developer", "user-1"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); await post( "/extensions/v2/submissions", await authHeaders("user-1"), @@ -574,8 +623,8 @@ describe("Extensions API v2", () => { describe("approve / reject", () => { it("does not approve a former owner's payload when ownership changes at approval", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); - seedDeveloper("new-developer", "user-1"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); const created = await post( "/extensions/v2/submissions", await authHeaders("user-1"), @@ -583,22 +632,24 @@ describe("Extensions API v2", () => { ); const { result } = (await created.json()) as { result: { id: string } }; - tables.raceOwnerChangeOnSubmissionApprovalTo = "user-2"; + // ownership_epoch is captured on the submission at creation time and + // only compared later, so unlike the deleteOwn/upsertOwn races below, + // simply changing ownership before the approve call (rather than + // mid-request) reproduces this exactly. + await bumpDeveloperOwnership(db, "new-developer", "user-2"); const approved = await post( `/extensions/v2/submissions/${result.id}/approve`, await authHeaders("mod-1"), {} ); expect(approved.status).toBe(409); - expect(tables.extension_submissions.get(result.id)?.status).toBe( - "pending" - ); - expect(tables.extensions.size).toBe(0); + expect((await getSubmission(db, result.id))?.status).toBe("pending"); + expect(await countExtensions(db)).toBe(0); }); - it("reverts to pending if the write-through fails after a successful claim", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); - seedDeveloper("new-developer", "user-1"); + it("leaves the submission pending if the extension write-through fails mid-batch", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); const created = await post( "/extensions/v2/submissions", @@ -607,32 +658,46 @@ describe("Extensions API v2", () => { ); const { result } = (await created.json()) as { result: { id: string } }; - tables.forceExtensionWriteFailure = true; + // approve()'s three statements (submission status, developer, extension) + // run as one atomic db.batch() call, so D1 itself rolls back the whole + // thing on any failure - there's no app-level "revert" to test, and no + // way to make the earlier statements really commit before this one + // fails (see db-interceptor.ts). This verifies that guarantee end to + // end: a failure on the last statement still leaves nothing committed. + env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { + if ( + sql.includes("INSERT INTO") && + sql.includes("extensions") && + !sql.includes("extension_submissions") + ) { + throw new Error("simulated write-through failure"); + } + }); const approved = await post( `/extensions/v2/submissions/${result.id}/approve`, await authHeaders("mod-1"), {} ); expect(approved.status).toBe(500); - expect(tables.extensions.size).toBe(0); + expect(await countExtensions(db)).toBe(0); - const stored = tables.extension_submissions.get(result.id); + const stored = await getSubmission(db, result.id); expect(stored?.status).toBe("pending"); // Recovers cleanly once the underlying failure is gone. - tables.forceExtensionWriteFailure = false; + env.DB_EXTENSIONS = db; const retried = await post( `/extensions/v2/submissions/${result.id}/approve`, await authHeaders("mod-1"), {} ); expect(retried.status).toBe(200); - expect(tables.extensions.size).toBe(1); + expect(await countExtensions(db)).toBe(1); }); it("approves a submission and it becomes visible via the v1 read path", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); - seedDeveloper("new-developer", "user-1"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); const created = await post( "/extensions/v2/submissions", @@ -664,7 +729,7 @@ describe("Extensions API v2", () => { }); it("blocks non-moderators from approving", async () => { - seedDeveloper("new-developer", "user-1"); + await seedDeveloper("new-developer", "user-1"); const created = await post( "/extensions/v2/submissions", await authHeaders("user-1"), @@ -681,8 +746,8 @@ describe("Extensions API v2", () => { }); it("rejects approving a submission that is not pending", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); - seedDeveloper("new-developer", "user-1"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); const created = await post( "/extensions/v2/submissions", await authHeaders("user-1"), @@ -702,11 +767,11 @@ describe("Extensions API v2", () => { ); expect(secondApprove.status).toBe(409); // The second (raced) approve must not write through again. - expect(tables.extensions.size).toBe(1); + expect(await countExtensions(db)).toBe(1); }); it("updates the existing row instead of duplicating it when an edit's id differs only by case", async () => { - tables.developers.set("owner-developer", { + await insertDeveloper(db, { id: "owner-developer", type: "user", name: "Owner", @@ -714,7 +779,7 @@ describe("Extensions API v2", () => { owner_user_id: "owner-1" }); // Legacy v1 data can have mixed-case ids; v2 submissions must be lowercase. - tables.extensions.set("Existing-Ext", { + await insertExtension(db, { id: "Existing-Ext", type: "mod", author_id: "owner-developer", @@ -729,7 +794,7 @@ describe("Extensions API v2", () => { version: "1.0.0", download_url: "https://e.com/d.zip" }); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const created = await post( "/extensions/v2/submissions", @@ -748,14 +813,14 @@ describe("Extensions API v2", () => { ); expect(approved.status).toBe(200); - expect(tables.extensions.size).toBe(1); - const stored = tables.extensions.get("Existing-Ext"); + expect(await countExtensions(db)).toBe(1); + const stored = await getExtension(db, "Existing-Ext"); expect(stored?.name).toBe("New Extension"); }); it("requires a review_note to reject", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); - seedDeveloper("new-developer", "user-1"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); const created = await post( "/extensions/v2/submissions", await authHeaders("user-1"), @@ -772,8 +837,8 @@ describe("Extensions API v2", () => { }); it("rejects a submission with a note", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); - seedDeveloper("new-developer", "user-1"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); const created = await post( "/extensions/v2/submissions", await authHeaders("user-1"), @@ -789,7 +854,7 @@ describe("Extensions API v2", () => { expect(res.status).toBe(200); const body = (await res.json()) as { result: { status: string } }; expect(body.result.status).toBe("rejected"); - expect(tables.extensions.size).toBe(0); + expect(await countExtensions(db)).toBe(0); }); }); @@ -805,7 +870,7 @@ describe("Extensions API v2", () => { const data = (await res.json()) as { result: { approved: boolean } }; expect(data.result.approved).toBe(false); - const stored = tables.developers.get("dev-developer"); + const stored = await getDeveloper(db, "dev-developer"); expect(stored).toBeDefined(); expect(stored?.approved_at).toBeNull(); }); @@ -829,7 +894,7 @@ describe("Extensions API v2", () => { }; expect(data.result.name).toBe("Renamed Developer"); expect(data.result.approved).toBe(false); - expect(tables.developers.get("dev-developer")?.name).toBe( + expect((await getDeveloper(db, "dev-developer"))?.name).toBe( "Renamed Developer" ); }); @@ -852,7 +917,7 @@ describe("Extensions API v2", () => { const statuses = [resA.status, resB.status].sort(); expect(statuses).toEqual([200, 409]); - const ownedDevelopers = [...tables.developers.values()].filter( + const ownedDevelopers = (await listDevelopers(db)).filter( (a) => a.owner_user_id === "user-1" ); expect(ownedDevelopers).toHaveLength(1); @@ -894,7 +959,7 @@ describe("Extensions API v2", () => { it("verifies a new profile when the creator's linked GitHub org matches the id", async () => { mockGithubEntity("Organization"); - tables.users.set("user-1", { + await insertUser(db, { id: "user-1", github_login: "someone", github_orgs: JSON.stringify(["acme-org"]) @@ -915,7 +980,7 @@ describe("Extensions API v2", () => { it("blocks creating a profile whose id matches a real GitHub org/user the creator doesn't control", async () => { mockGithubEntity("Organization"); - tables.users.set("user-1", { + await insertUser(db, { id: "user-1", github_login: "someone", github_orgs: JSON.stringify(["some-other-org"]) @@ -930,12 +995,12 @@ describe("Extensions API v2", () => { expect(res.status).toBe(403); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(tables.developers.has("acme-org")).toBe(false); + expect(await hasDeveloper(db, "acme-org")).toBe(false); }); it("blocks creating a profile whose id matches a real GitHub entity of the opposite type", async () => { mockGithubEntity("Organization"); - tables.users.set("user-1", { + await insertUser(db, { id: "user-1", github_login: "someone", github_orgs: JSON.stringify(["acme-org"]) @@ -950,12 +1015,12 @@ describe("Extensions API v2", () => { expect(res.status).toBe(403); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(tables.developers.has("acme-org")).toBe(false); + expect(await hasDeveloper(db, "acme-org")).toBe(false); }); it("falls back to unverified creation when the creator has no linked GitHub identity", async () => { mockGithubEntity("Organization"); - // No row in tables.users for user-1 — never linked GitHub. + // No row in users for user-1 — never linked GitHub. const res = await put( "/extensions/v2/developers/me", @@ -972,7 +1037,11 @@ describe("Extensions API v2", () => { it("fails creation rather than falling back to unverified when the caller's GitHub identity lookup errors", async () => { mockGithubEntity("Organization"); - tables.forceGithubIdentityLookupFailure = true; + env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { + if (sql.includes("github_login") && sql.includes("github_orgs")) { + throw new Error("D1_ERROR: simulated database failure"); + } + }); const res = await put( "/extensions/v2/developers/me", @@ -981,7 +1050,8 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(500); - expect(tables.developers.has("acme-org")).toBe(false); + env.DB_EXTENSIONS = db; + expect(await hasDeveloper(db, "acme-org")).toBe(false); }); it.each(["claims", "unapproved"])( @@ -1003,7 +1073,7 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper() ); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const approved = await post( "/extensions/v2/developers/dev-developer/approve", await authHeaders("mod-1"), @@ -1032,23 +1102,38 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper() ); - tables.raceOwnerChangeOnProfileUpdateTo = "user-2"; + + // Fires when upsertOwn's existing-profile UPDATE (identified by + // touching content_revision but not ownership_epoch, which only the + // ownership-transfer statements touch) is about to run - the DB + // change lands between existingOwn's read (which still sees user-1 + // as owner) and this guarded write. + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if ( + sql.includes("developers") && + sql.includes("content_revision") && + !sql.includes("ownership_epoch") + ) { + await bumpDeveloperOwnership(db, "dev-developer", "user-2"); + } + }); const raced = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), sampleDeveloper({ name: "Former owner write" }) ); + env.DB_EXTENSIONS = db; expect(raced.status).toBe(409); - expect(tables.developers.get("dev-developer")?.name).toBe( + expect((await getDeveloper(db, "dev-developer"))?.name).toBe( "Dev Developer" ); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-2" ); expect( - [...tables.developer_history.values()].filter( + (await listDeveloperHistory(db)).filter( (row) => row.developer_id === "dev-developer" ) ).toHaveLength(1); @@ -1072,7 +1157,7 @@ describe("Extensions API v2", () => { expect(data.result.avatar_url).toBe("https://example.com/avatar.png"); expect(data.result.contact_email).toBe("dev@example.com"); - const stored = tables.developers.get("dev-developer"); + const stored = await getDeveloper(db, "dev-developer"); expect(stored?.avatar_url).toBe("https://example.com/avatar.png"); expect(stored?.contact_email).toBe("dev@example.com"); }); @@ -1101,7 +1186,7 @@ describe("Extensions API v2", () => { expect(data.result.avatar_url).toBe("https://example.com/new.png"); expect(data.result.contact_email).toBe("new@example.com"); - const stored = tables.developers.get("dev-developer"); + const stored = await getDeveloper(db, "dev-developer"); expect(stored?.avatar_url).toBe("https://example.com/new.png"); expect(stored?.contact_email).toBe("new@example.com"); }); @@ -1158,7 +1243,7 @@ describe("Extensions API v2", () => { }); it("409s when the profile still has published extensions", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const res = await del( "/extensions/v2/developers/me", @@ -1178,17 +1263,13 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper() ); - tables.extension_submissions.set("sub-1", { + await insertSubmission(db, { id: "sub-1", extension_id: null, developer_id: "dev-developer", submitted_by: "user-1", status: "pending", - payload: JSON.stringify(samplePayload()), - reviewer_id: null, - review_note: null, - created_at: new Date().toISOString(), - reviewed_at: null + payload: JSON.stringify(samplePayload()) }); const res = await del( @@ -1210,15 +1291,13 @@ describe("Extensions API v2", () => { "/extensions/v2/developers/dev-developer/transfer", await authHeaders("user-1") ); - tables.developer_claims.set("claim-1", { + await insertDeveloperClaim(db, { id: "claim-1", developer_id: "dev-developer", claimant_id: "user-2", status: "rejected", - note: null, review_note: "no", reviewer_id: "mod-1", - created_at: new Date().toISOString(), reviewed_at: new Date().toISOString() }); @@ -1229,17 +1308,17 @@ describe("Extensions API v2", () => { expect(res.status).toBe(200); expect( - [...tables.developer_transfers.values()].filter( + (await listDeveloperTransfers(db)).filter( (r) => r.developer_id === "dev-developer" ) ).toHaveLength(0); expect( - [...tables.developer_claims.values()].filter( + (await listDeveloperClaims(db)).filter( (r) => r.developer_id === "dev-developer" ) ).toHaveLength(0); expect( - [...tables.developer_history.values()].filter( + (await listDeveloperHistory(db)).filter( (r) => r.developer_id === "dev-developer" ).length ).toBeGreaterThan(0); @@ -1251,16 +1330,26 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper() ); - tables.users.set("user-2", { id: "user-2" }); + await insertUser(db, { id: "user-2" }); // Simulates a transfer/claim landing in the window between deleteOwn's - // initial "find my profile" lookup and its guarded delete — the + // initial "find my profile" lookup and its guarded delete - the // delete must re-check ownership at that point, not trust the lookup. - tables.raceOwnerChangeTo = "user-2"; + // deleteTransfersStmt is the first statement in deleteOwn's batch, so + // firing this before it reproduces the race exactly. + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if ( + sql.includes("DELETE FROM") && + sql.includes("developer_transfers") + ) { + await bumpDeveloperOwnership(db, "dev-developer", "user-2"); + } + }); const res = await del( "/extensions/v2/developers/me", await authHeaders("user-1") ); + env.DB_EXTENSIONS = db; expect(res.status).toBe(404); const stillThere = await get( @@ -1285,7 +1374,7 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper({ name: "Revision two" }) ); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const stale = await post( "/extensions/v2/developers/dev-developer/approve", @@ -1307,7 +1396,7 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper() ); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const approve = await post( "/extensions/v2/developers/dev-developer/approve", @@ -1337,7 +1426,7 @@ describe("Extensions API v2", () => { }); it("404s approving a nonexistent developer", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await post( "/extensions/v2/developers/no-such-developer/approve", @@ -1366,7 +1455,7 @@ describe("Extensions API v2", () => { await authHeaders("user-2"), sampleDeveloper({ id: "other-developer", name: "Other Developer" }) ); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); await post( "/extensions/v2/developers/dev-developer/approve", await authHeaders("mod-1"), @@ -1424,7 +1513,7 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper() ); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( "/extensions/v2/developers/dev-developer/history", @@ -1456,7 +1545,7 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper({ name: "Edited Name" }) ); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( "/extensions/v2/developers/dev-developer/history", @@ -1473,7 +1562,7 @@ describe("Extensions API v2", () => { }); it("returns an empty array for a developer with no history", async () => { - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( "/extensions/v2/developers/no-such-developer/history", @@ -1513,7 +1602,7 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(409); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const history = await get( "/extensions/v2/developers/dev-developer/history", await authHeaders("mod-1") @@ -1567,7 +1656,7 @@ describe("Extensions API v2", () => { await authHeaders("user-1"), sampleDeveloper() ); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); await post( "/extensions/v2/developers/dev-developer/approve", await authHeaders("mod-1"), @@ -1592,7 +1681,7 @@ describe("Extensions API v2", () => { }; expect(accepted.result.id).toBe("dev-developer"); expect(accepted.result.approved).toBe(false); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-2" ); @@ -1602,7 +1691,7 @@ describe("Extensions API v2", () => { { token } ); expect(acceptAgain.status).toBe(404); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-2" ); }); @@ -1627,7 +1716,7 @@ describe("Extensions API v2", () => { { token: token1 } ); expect(accept1.status).toBe(200); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-2" ); @@ -1644,7 +1733,7 @@ describe("Extensions API v2", () => { { token: token2 } ); expect(accept2.status).toBe(200); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-3" ); @@ -1656,7 +1745,7 @@ describe("Extensions API v2", () => { { token: token1 } ); expect(replay.status).toBe(404); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-3" ); }); @@ -1675,9 +1764,7 @@ describe("Extensions API v2", () => { const token = ((await initiate.json()) as { result: { token: string } }) .result.token; - for (const transfer of tables.developer_transfers.values()) { - transfer.expires_at = "2000-01-01 00:00:00"; - } + await expireAllDeveloperTransfers(db); const accept = await post( "/extensions/v2/developers/transfers/accept", @@ -1740,7 +1827,7 @@ describe("Extensions API v2", () => { { token } ); expect(accept.status).toBe(409); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-1" ); }); @@ -1765,7 +1852,7 @@ describe("Extensions API v2", () => { { token } ); expect(accept.status).toBe(409); - expect(tables.developers.get("dev-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-1" ); }); @@ -1805,7 +1892,7 @@ describe("Extensions API v2", () => { describe("developer claims", () => { it("lets a user claim an unowned developer, visible to the claimant and moderators", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); const res = await post( "/extensions/v2/developers/legacy-developer/claim", @@ -1826,7 +1913,7 @@ describe("Extensions API v2", () => { expect(mineData.result.map((c) => c.id)).toEqual([created.result.id]); expect(mineData.result[0].status).toBe("pending"); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const pending = await get( "/extensions/v2/developers/claims", await authHeaders("mod-1") @@ -1855,7 +1942,7 @@ describe("Extensions API v2", () => { }); it("does not create a duplicate row for a second claim while one is already pending", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); const first = await post( "/extensions/v2/developers/legacy-developer/claim", @@ -1870,11 +1957,11 @@ describe("Extensions API v2", () => { {} ); expect(second.status).toBe(409); - expect(tables.developer_claims.size).toBe(1); + expect(await countDeveloperClaims(db)).toBe(1); }); it("does not re-check GitHub when replaying an already-pending claim", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); await post( "/extensions/v2/developers/legacy-developer/claim", @@ -1895,8 +1982,8 @@ describe("Extensions API v2", () => { }); it("still verifies GitHub ownership on a retry after the prior claim was rejected", async () => { - seedUnownedDeveloper("legacy-developer"); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const first = await post( "/extensions/v2/developers/legacy-developer/claim", @@ -1915,13 +2002,13 @@ describe("Extensions API v2", () => { // still be blocked rather than silently creating an unverified claim // just because the prior (now-rejected) row cleared the pending guard. mockGithubEntity("Organization"); - tables.users.set("user-1", { + await insertUser(db, { id: "user-1", github_login: "someone", github_orgs: JSON.stringify(["some-other-org"]) }); - const claimCountBefore = tables.developer_claims.size; + const claimCountBefore = await countDeveloperClaims(db); const retry = await post( "/extensions/v2/developers/legacy-developer/claim", await authHeaders("user-1"), @@ -1931,11 +2018,11 @@ describe("Extensions API v2", () => { expect(retry.status).toBe(403); const body = (await retry.json()) as { error: { code: string } }; expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(tables.developer_claims.size).toBe(claimCountBefore); + expect(await countDeveloperClaims(db)).toBe(claimCountBefore); }); it("rejects a claim from a user who already owns a different profile", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); await put( "/extensions/v2/developers/me", await authHeaders("user-1"), @@ -1951,8 +2038,8 @@ describe("Extensions API v2", () => { }); it("approving a claim transfers ownership and auto-rejects competing claims", async () => { - seedUnownedDeveloper("legacy-developer"); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const claim1 = await post( "/extensions/v2/developers/legacy-developer/claim", @@ -1980,11 +2067,11 @@ describe("Extensions API v2", () => { }; expect(approved.result.id).toBe("legacy-developer"); expect(approved.result.approved).toBe(false); - expect(tables.developers.get("legacy-developer")?.owner_user_id).toBe( + expect((await getDeveloper(db, "legacy-developer"))?.owner_user_id).toBe( "user-1" ); - const rejectedClaim = tables.developer_claims.get(claim2Id); + const rejectedClaim = await getDeveloperClaim(db, claim2Id); expect(rejectedClaim?.status).toBe("rejected"); expect(rejectedClaim?.review_note).toBe( "Another claim on this profile was approved" @@ -1992,8 +2079,8 @@ describe("Extensions API v2", () => { }); it("lets a moderator reject a claim with a review note, leaving the developer unowned", async () => { - seedUnownedDeveloper("legacy-developer"); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const claim = await post( "/extensions/v2/developers/legacy-developer/claim", @@ -2017,12 +2104,12 @@ describe("Extensions API v2", () => { "Not enough evidence of maintainership" ); expect( - tables.developers.get("legacy-developer")?.owner_user_id + (await getDeveloper(db, "legacy-developer"))?.owner_user_id ).toBeNull(); }); it("verifies a claim when the claimant's linked GitHub org matches the developer id", async () => { - tables.developers.set("legacy-developer", { + await insertDeveloper(db, { id: "legacy-developer", type: "organization", name: "Legacy Developer", @@ -2030,7 +2117,7 @@ describe("Extensions API v2", () => { owner_user_id: null }); mockGithubEntity("Organization"); - tables.users.set("user-1", { + await insertUser(db, { id: "user-1", github_login: "someone", github_orgs: JSON.stringify(["legacy-developer"]) @@ -2049,7 +2136,7 @@ describe("Extensions API v2", () => { }); it("verifies a claim when the claimant's linked GitHub login matches a user-type developer id", async () => { - tables.developers.set("legacy-user", { + await insertDeveloper(db, { id: "legacy-user", type: "user", name: "Legacy User", @@ -2057,7 +2144,7 @@ describe("Extensions API v2", () => { owner_user_id: null }); mockGithubEntity("User"); - tables.users.set("user-1", { + await insertUser(db, { id: "user-1", github_login: "legacy-user", github_orgs: JSON.stringify([]) @@ -2076,7 +2163,7 @@ describe("Extensions API v2", () => { }); it("blocks a claim outright when the claimant's linked GitHub identity doesn't match", async () => { - tables.developers.set("legacy-developer", { + await insertDeveloper(db, { id: "legacy-developer", type: "organization", name: "Legacy Developer", @@ -2084,7 +2171,7 @@ describe("Extensions API v2", () => { owner_user_id: null }); mockGithubEntity("Organization"); - tables.users.set("user-1", { + await insertUser(db, { id: "user-1", github_login: "someone", github_orgs: JSON.stringify(["some-other-org"]) @@ -2098,13 +2185,13 @@ describe("Extensions API v2", () => { expect(res.status).toBe(403); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(tables.developer_claims.size).toBe(0); + expect(await countDeveloperClaims(db)).toBe(0); }); it("falls back to unverified manual review when the claimant has no linked GitHub identity", async () => { - seedUnownedDeveloper("legacy-developer"); // type: "user" + await seedUnownedDeveloper("legacy-developer"); // type: "user" mockGithubEntity("User"); - // No row in tables.users for user-1 — never linked GitHub. + // No row in users for user-1 — never linked GitHub. const res = await post( "/extensions/v2/developers/legacy-developer/claim", @@ -2119,7 +2206,7 @@ describe("Extensions API v2", () => { }); it("falls back to unverified manual review when no matching GitHub org/user exists for the id", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); mockGithubEntityNotFound(); const res = await post( @@ -2135,7 +2222,7 @@ describe("Extensions API v2", () => { }); it("blocks non-moderators from the claims queue and review routes", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); const claim = await post( "/extensions/v2/developers/legacy-developer/claim", await authHeaders("user-1"), @@ -2165,7 +2252,7 @@ describe("Extensions API v2", () => { }); it("lets a claimant cancel their own pending claim", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); const claim = await post( "/extensions/v2/developers/legacy-developer/claim", await authHeaders("user-1"), @@ -2183,11 +2270,11 @@ describe("Extensions API v2", () => { result: { id: string; cancelled: boolean }; }; expect(cancelled.result).toEqual({ id: claimId, cancelled: true }); - expect(tables.developer_claims.has(claimId)).toBe(false); + expect(await getDeveloperClaim(db, claimId)).toBeNull(); }); it("rejects cancelling a claim that belongs to someone else", async () => { - seedUnownedDeveloper("legacy-developer"); + await seedUnownedDeveloper("legacy-developer"); const claim = await post( "/extensions/v2/developers/legacy-developer/claim", await authHeaders("user-1"), @@ -2201,12 +2288,12 @@ describe("Extensions API v2", () => { await authHeaders("user-2") ); expect(cancel.status).toBe(404); - expect(tables.developer_claims.has(claimId)).toBe(true); + expect(await getDeveloperClaim(db, claimId)).not.toBeNull(); }); it("rejects cancelling a claim that is no longer pending", async () => { - seedUnownedDeveloper("legacy-developer"); - tables.users.set("mod-1", { id: "mod-1", is_moderator: 1 }); + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); const claim = await post( "/extensions/v2/developers/legacy-developer/claim", await authHeaders("user-1"), @@ -2225,13 +2312,13 @@ describe("Extensions API v2", () => { await authHeaders("user-1") ); expect(cancel.status).toBe(404); - expect(tables.developer_claims.get(claimId)?.status).toBe("rejected"); + expect((await getDeveloperClaim(db, claimId))?.status).toBe("rejected"); }); }); describe("GET /developers/{id}", () => { it("returns a developer's public profile without contact_email, unauthenticated", async () => { - tables.developers.set("public-dev", { + await insertDeveloper(db, { id: "public-dev", type: "organization", name: "Public Dev", @@ -2239,9 +2326,7 @@ describe("Extensions API v2", () => { avatar_url: "https://example.com/avatar.png", contact_email: "private@example.com", owner_user_id: "user-1", - approved_at: new Date().toISOString(), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() + approved_at: new Date().toISOString() }); const res = await get("/extensions/v2/developers/public-dev", {}); @@ -2266,7 +2351,7 @@ describe("Extensions API v2", () => { describe("GET /extensions", () => { it("lists published extensions with the developer embedded", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const res = await get("/extensions/v2/extensions", {}); expect(res.status).toBe(200); @@ -2279,7 +2364,7 @@ describe("Extensions API v2", () => { }); it("filters by type", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const matching = await get("/extensions/v2/extensions?type=mod", {}); const matchingBody = (await matching.json()) as { result: unknown[] }; @@ -2298,7 +2383,7 @@ describe("Extensions API v2", () => { }); it("filters by developer_id", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const matching = await get( "/extensions/v2/extensions?developer_id=owner-developer", @@ -2320,7 +2405,7 @@ describe("Extensions API v2", () => { describe("GET /extensions/{id}", () => { it("gets a single extension, case-insensitively", async () => { - seedOwnedExtension(); + await seedOwnedExtension(); const res = await get("/extensions/v2/extensions/EXISTING-EXT", {}); expect(res.status).toBe(200); @@ -2337,36 +2422,14 @@ describe("Extensions API v2", () => { expect(res.status).toBe(404); }); - it("still returns a usable developer.id when the developer row is missing", async () => { - // author_id isn't a hard FK (0001_add_v2_tables.sql), so this can - // happen without any application bug — the embedded developer must - // still satisfy the schema (id: string) rather than surface a null. - tables.extensions.set("orphaned-ext", { - id: "orphaned-ext", - type: "mod", - author_id: "no-such-developer", - name: "Orphaned", - description: "d", - releases: "[]", - website: "https://e.com", - license: '{"name":"MIT"}', - icon_url: null, - readme: "r", - source: '{"type":"github","repo":"example/orphaned"}', - version: "1.0.0", - download_url: "https://e.com/d.zip" - }); - - const res = await get("/extensions/v2/extensions/orphaned-ext", {}); - expect(res.status).toBe(200); - const body = (await res.json()) as { result: { developer: unknown } }; - expect(body.result.developer).toEqual({ - id: "no-such-developer", - type: "user", - name: "", - approved: false - }); - }); + // A test previously lived here asserting parseExtensionRow's fallback + // for a "missing developer row" (an extension with an author_id that + // doesn't exist). It was deleted: extensions.author_id has always been + // a hard FK to developers(id) in the real schema.sql - the old mock + // just never enforced it, so that state was never actually reachable + // in production. The defensive COALESCE/fallback in + // extensions-database.ts is harmless to keep, just untestable this way + // against real D1. }); describe("OpenAPI docs", () => { diff --git a/test/services/extensions/v2/mock-db.ts b/test/services/extensions/v2/mock-db.ts deleted file mode 100644 index 4b7060c..0000000 --- a/test/services/extensions/v2/mock-db.ts +++ /dev/null @@ -1,1186 +0,0 @@ -type Row = Record; - -export interface MockTables { - developers: Map; - extensions: Map; - extension_submissions: Map; - developer_history: Map; - developer_transfers: Map; - developer_claims: Map; - users: Map; - // Test-only seam for simulating a write-through failure during approve(). - forceExtensionWriteFailure?: boolean; - // Test-only seam for simulating a profile changing ownership in the - // window between deleteOwn()'s initial lookup and its guarded delete — - // fires (once) the first time that lookup runs, mutating the row so the - // guard sees a different owner than the caller who's mid-request. - raceOwnerChangeTo?: string; - // Fires immediately before a moderator's guarded submission approval. - raceOwnerChangeOnSubmissionApprovalTo?: string; - raceOwnerChangeOnProfileUpdateTo?: string; - // Test-only seam for simulating a DB/schema failure while looking up the - // caller's linked GitHub identity during claim()/upsertOwn() verification. - forceGithubIdentityLookupFailure?: boolean; -} - -export function createTables(): MockTables { - return { - developers: new Map(), - extensions: new Map(), - extension_submissions: new Map(), - developer_history: new Map(), - developer_transfers: new Map(), - developer_claims: new Map(), - users: new Map() - }; -} - -function ok>( - results: T[] = [], - changes = results.length -): D1Result { - return { - success: true, - results, - meta: { - duration: 0, - last_row_id: 0, - changes, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; -} - -class MockStatement implements D1PreparedStatement { - private params: unknown[] = []; - private changes = 0; - - constructor( - private tables: MockTables, - private query: string - ) {} - - bind(...params: unknown[]): D1PreparedStatement { - this.params = params; - return this; - } - - raw: D1PreparedStatement["raw"] = (() => { - throw new Error("not implemented"); - }) as D1PreparedStatement["raw"]; - - get normalizedQuery(): string { - return this.query.replace(/\s+/g, " ").trim(); - } - - private isEligibleForDeveloperDeletion( - developerId: string, - ownerUserId: unknown - ): boolean { - const developer = this.tables.developers.get(developerId); - if (!developer || developer.owner_user_id !== ownerUserId) return false; - const hasExtensions = [...this.tables.extensions.values()].some( - (r) => r.author_id === developerId - ); - if (hasExtensions) return false; - const hasPendingSubmission = [ - ...this.tables.extension_submissions.values() - ].some((r) => r.developer_id === developerId && r.status === "pending"); - return !hasPendingSubmission; - } - - private execute(): Row[] { - const q = this.normalizedQuery; - const p = this.params; - - // v2's SELECT_EXTENSIONS join (extensions-database.ts): public - // list/getById reads, with an optional WHERE for type / author_id / - // case-insensitive id, applied in that order to match how the params - // are bound. - if (q.startsWith("SELECT e.id, e.type, e.name, e.description,")) { - let rows = [...this.tables.extensions.values()]; - let paramIdx = 0; - if (q.includes("e.type = ?")) { - rows = rows.filter((r) => r.type === p[paramIdx]); - paramIdx++; - } - if (q.includes("e.author_id = ?")) { - rows = rows.filter((r) => r.author_id === p[paramIdx]); - paramIdx++; - } - if (q.includes("LOWER(e.id) = LOWER(?)")) { - const id = String(p[paramIdx]).toLowerCase(); - rows = rows.filter((r) => String(r.id).toLowerCase() === id); - paramIdx++; - } - return rows.map((r) => { - const developer = this.tables.developers.get(String(r.author_id)); - return { - id: r.id, - type: r.type, - name: r.name, - description: r.description, - releases: r.releases, - website: r.website, - license: r.license, - icon_url: r.icon_url, - readme: r.readme, - source: r.source, - version: r.version, - download_url: r.download_url, - developer_id: developer?.id ?? r.author_id, - developer_type: developer?.type ?? "user", - developer_name: developer?.name ?? "", - developer_url: developer?.url ?? null, - developer_avatar_url: developer?.avatar_url ?? null, - developer_approved_at: developer?.approved_at ?? null - }; - }); - } - - // v1's SELECT_EXTENSIONS join (database.ts), for cross-service verification - // that an approved v2 submission is visible via the v1 read path. - if (q.startsWith("SELECT e.id, e.type, e.author_id,")) { - let rows = [...this.tables.extensions.values()]; - if (q.includes("WHERE e.type = ?")) { - rows = rows.filter((r) => r.type === p[0]); - } else if (q.includes("WHERE LOWER(e.id) = LOWER(?)")) { - const id = String(p[0]).toLowerCase(); - rows = rows.filter((r) => String(r.id).toLowerCase() === id); - } - return rows.map((r) => { - const developer = this.tables.developers.get(String(r.author_id)); - return { - id: r.id, - type: r.type, - author_id: r.author_id, - author_type: developer?.type ?? "user", - author_name: developer?.name ?? "", - author_url: developer?.url ?? null, - name: r.name, - description: r.description, - releases: r.releases, - website: r.website, - license: r.license, - icon_url: r.icon_url, - readme: r.readme, - source: r.source, - version: r.version, - download_url: r.download_url - }; - }); - } - - if ( - q.startsWith( - "SELECT id, author_id FROM extensions WHERE LOWER(id) = LOWER(?)" - ) - ) { - const id = String(p[0]).toLowerCase(); - const row = [...this.tables.extensions.values()].find( - (r) => String(r.id).toLowerCase() === id - ); - return row ? [row] : []; - } - - if ( - q.startsWith( - "SELECT COUNT(*) AS count FROM extensions WHERE author_id = ?" - ) - ) { - const count = [...this.tables.extensions.values()].filter( - (r) => r.author_id === p[0] - ).length; - return [{ count }]; - } - - if ( - q.startsWith( - "SELECT COUNT(*) AS count FROM extension_submissions WHERE developer_id = ? AND status = 'pending'" - ) - ) { - const count = [...this.tables.extension_submissions.values()].filter( - (r) => r.developer_id === p[0] && r.status === "pending" - ).length; - return [{ count }]; - } - - if ( - q.startsWith( - "SELECT owner_user_id, ownership_epoch FROM developers WHERE id = ?" - ) || - q.startsWith("SELECT owner_user_id FROM developers WHERE id = ?") - ) { - const row = this.tables.developers.get(String(p[0])); - return row ? [row] : []; - } - - if ( - q.startsWith( - "SELECT type FROM developers WHERE id = ? AND owner_user_id IS NULL" - ) - ) { - const row = this.tables.developers.get(String(p[0])); - return row && row.owner_user_id === null ? [{ type: row.type }] : []; - } - - if (q.startsWith("SELECT id FROM developers WHERE owner_user_id = ?")) { - const row = [...this.tables.developers.values()].find( - (r) => r.owner_user_id === p[0] - ); - if (row && this.tables.raceOwnerChangeTo !== undefined) { - row.owner_user_id = this.tables.raceOwnerChangeTo; - this.tables.raceOwnerChangeTo = undefined; - } - return row ? [{ id: row.id }] : []; - } - - if (q.startsWith("SELECT * FROM developers WHERE owner_user_id = ?")) { - const row = [...this.tables.developers.values()].find( - (r) => r.owner_user_id === p[0] - ); - return row ? [row] : []; - } - - if ( - q.startsWith( - "SELECT * FROM developers WHERE id = ? AND owner_user_id = ?" - ) - ) { - const row = this.tables.developers.get(String(p[0])); - return row && row.owner_user_id === p[1] ? [row] : []; - } - - if (q.startsWith("SELECT * FROM developers WHERE id = ?")) { - const row = this.tables.developers.get(String(p[0])); - return row ? [row] : []; - } - - if (q.startsWith("SELECT * FROM developers ORDER BY name")) { - return [...this.tables.developers.values()].sort((a, b) => - String(a.name ?? "").localeCompare(String(b.name ?? "")) - ); - } - - if (q.startsWith("SELECT * FROM developers WHERE approved_at IS NULL")) { - return [...this.tables.developers.values()] - .filter((r) => (r.approved_at ?? null) === null) - .sort((a, b) => - String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")) - ); - } - - if ( - q.startsWith( - "INSERT INTO developers (id, type, name, url, avatar_url, contact_email, owner_user_id, approved_at, github_org_verified, github_verification_note, created_at, updated_at)" - ) - ) { - const [ - id, - type, - name, - url, - avatar_url, - contact_email, - owner_user_id, - github_org_verified, - github_verification_note - ] = p; - // Mirrors idx_developers_owner_unique: one profile per non-null owner. - const ownerTaken = [...this.tables.developers.values()].some( - (r) => owner_user_id !== null && r.owner_user_id === owner_user_id - ); - if (ownerTaken) { - throw new Error( - "D1_ERROR: UNIQUE constraint failed: developers.owner_user_id" - ); - } - const now = new Date().toISOString(); - this.tables.developers.set(String(id), { - id, - type, - name, - url, - avatar_url, - contact_email, - owner_user_id, - approved_at: null, - github_org_verified, - github_verification_note, - ownership_epoch: 1, - content_revision: 1, - approved_revision: null, - approved_by: null, - created_at: now, - updated_at: now - }); - this.changes = 1; - return []; - } - - if ( - q.startsWith( - "UPDATE developers SET type = ?, name = ?, url = ?, avatar_url = ?, contact_email = ?" - ) - ) { - const [type, name, url, avatar_url, contact_email, id, owner_user_id] = p; - const row = this.tables.developers.get(String(id)); - if (row && this.tables.raceOwnerChangeOnProfileUpdateTo !== undefined) { - row.owner_user_id = this.tables.raceOwnerChangeOnProfileUpdateTo; - row.ownership_epoch = Number(row.ownership_epoch ?? 1) + 1; - this.tables.raceOwnerChangeOnProfileUpdateTo = undefined; - } - if ( - row && - (owner_user_id === undefined || row.owner_user_id === owner_user_id) - ) { - row.type = type; - row.name = name; - row.url = url; - row.avatar_url = avatar_url; - row.contact_email = contact_email; - row.approved_at = null; - row.approved_revision = null; - row.approved_by = null; - row.content_revision = Number(row.content_revision ?? 1) + 1; - row.updated_at = new Date().toISOString(); - this.changes = 1; - } - return []; - } - - if ( - q.startsWith( - "INSERT INTO developer_history (id, developer_id, type, name, url, changed_by, changed_at)" - ) - ) { - const [id, developer_id, type, name, url, changed_by] = p; - this.tables.developer_history.set(String(id), { - id, - developer_id, - type, - name, - url, - changed_by, - changed_at: new Date().toISOString() - }); - return []; - } - - if ( - q.startsWith( - "UPDATE developers SET type = ?, name = ?, url = ?, content_revision = content_revision + 1" - ) - ) { - const [type, name, url, id, owner_user_id, ownership_epoch] = p; - const row = this.tables.developers.get(String(id)); - if ( - row && - row.owner_user_id === owner_user_id && - Number(row.ownership_epoch ?? 1) === ownership_epoch - ) { - row.type = type; - row.name = name; - row.url = url; - row.content_revision = Number(row.content_revision ?? 1) + 1; - row.approved_at = null; - row.approved_revision = null; - row.approved_by = null; - row.updated_at = new Date().toISOString(); - this.changes = 1; - } - return []; - } - - if ( - q.startsWith( - "SELECT developer_id, type, name, url, changed_by, changed_at FROM developer_history WHERE developer_id = ?" - ) - ) { - const [developer_id] = p; - // Newest-first, with ties (same-second CURRENT_TIMESTAMP) broken by - // insertion order — mirrors the real query's `changed_at DESC, rowid DESC`. - return [...this.tables.developer_history.values()] - .filter((r) => r.developer_id === developer_id) - .reverse() - .sort((a, b) => - String(b.changed_at).localeCompare(String(a.changed_at)) - ); - } - - if ( - q.startsWith( - "UPDATE developer_transfers SET revoked_at = CURRENT_TIMESTAMP WHERE developer_id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND EXISTS" - ) - ) { - const [developer_id, owner_user_id] = p; - const developer = this.tables.developers.get(String(developer_id)); - let changes = 0; - if (developer?.owner_user_id === owner_user_id) { - for (const row of this.tables.developer_transfers.values()) { - if ( - row.developer_id === developer_id && - row.accepted_at === null && - row.revoked_at === null - ) { - row.revoked_at = new Date().toISOString(); - changes++; - } - } - } - this.changes = changes; - return []; - } - - if ( - q.startsWith( - "INSERT INTO developer_transfers (id, developer_id, token_hash, created_by, expires_at) SELECT ?, ?, ?, ?, ? WHERE EXISTS" - ) - ) { - const [ - id, - developer_id, - token_hash, - created_by, - expires_at, - checkDeveloperId, - owner_user_id - ] = p; - const developer = this.tables.developers.get(String(checkDeveloperId)); - if (developer?.owner_user_id === owner_user_id) { - this.tables.developer_transfers.set(String(id), { - id, - developer_id, - token_hash, - created_by, - created_at: new Date().toISOString(), - expires_at, - accepted_by: null, - accepted_at: null, - revoked_at: null - }); - this.changes = 1; - } else { - this.changes = 0; - } - return []; - } - - if ( - q.startsWith( - "UPDATE developer_transfers SET accepted_at = CURRENT_TIMESTAMP, accepted_by = ? WHERE token_hash = ?" - ) - ) { - const [accepted_by, token_hash, owner_user_id] = p; - const now = new Date(); - const ownsAny = [...this.tables.developers.values()].some( - (r) => r.owner_user_id === owner_user_id - ); - const row = [...this.tables.developer_transfers.values()].find( - (r) => - r.token_hash === token_hash && - r.accepted_at === null && - r.revoked_at === null && - new Date(`${String(r.expires_at).replace(" ", "T")}Z`) > now - ); - if (row && !ownsAny) { - row.accepted_at = new Date().toISOString(); - row.accepted_by = accepted_by; - this.changes = 1; - } else { - this.changes = 0; - } - return []; - } - - if ( - q.startsWith( - "SELECT 1 FROM developer_transfers WHERE token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > CURRENT_TIMESTAMP" - ) - ) { - const [token_hash] = p; - const now = new Date(); - const row = [...this.tables.developer_transfers.values()].find( - (r) => - r.token_hash === token_hash && - r.accepted_at === null && - r.revoked_at === null && - new Date(`${String(r.expires_at).replace(" ", "T")}Z`) > now - ); - return row ? [{ "1": 1 }] : []; - } - - if ( - q.startsWith( - "SELECT developer_id FROM developer_transfers WHERE token_hash = ?" - ) - ) { - const [token_hash] = p; - const row = [...this.tables.developer_transfers.values()].find( - (r) => r.token_hash === token_hash - ); - return row ? [{ developer_id: row.developer_id }] : []; - } - - if ( - q.startsWith("UPDATE developers SET owner_user_id = ?") && - q.includes("SELECT developer_id FROM developer_transfers") - ) { - const [owner_user_id, token_hash, accepted_by] = p; - const transfer = [...this.tables.developer_transfers.values()].find( - (r) => - r.token_hash === token_hash && - r.accepted_by === accepted_by && - r.accepted_at !== null - ); - const row = transfer - ? this.tables.developers.get(String(transfer.developer_id)) - : undefined; - if (row) { - row.owner_user_id = owner_user_id; - row.ownership_epoch = Number(row.ownership_epoch ?? 1) + 1; - row.content_revision = Number(row.content_revision ?? 1) + 1; - row.approved_at = null; - row.approved_revision = null; - row.approved_by = null; - row.updated_at = new Date().toISOString(); - this.changes = 1; - } else { - this.changes = 0; - } - return []; - } - - if ( - q.startsWith( - "UPDATE developers SET approved_at = CURRENT_TIMESTAMP, approved_revision = content_revision, approved_by = ? WHERE id = ? AND content_revision = ?" - ) - ) { - const [reviewer_id, id, expected_revision] = p; - const row = this.tables.developers.get(String(id)); - if (row && Number(row.content_revision ?? 1) === expected_revision) { - row.approved_at = new Date().toISOString(); - row.approved_revision = Number(row.content_revision ?? 1); - row.approved_by = reviewer_id; - this.changes = 1; - } - return []; - } - - if (q.startsWith("SELECT 1 FROM developers WHERE owner_user_id = ?")) { - const row = [...this.tables.developers.values()].find( - (r) => r.owner_user_id === p[0] - ); - return row ? [{ "1": 1 }] : []; - } - - if ( - q.startsWith( - "SELECT 1 FROM developer_claims WHERE developer_id = ? AND claimant_id = ? AND status = 'pending'" - ) - ) { - const row = [...this.tables.developer_claims.values()].find( - (r) => - r.developer_id === p[0] && - r.claimant_id === p[1] && - r.status === "pending" - ); - return row ? [{ "1": 1 }] : []; - } - - if ( - q.startsWith( - "INSERT INTO developer_claims (id, developer_id, claimant_id, note, github_org_verified, github_verification_note) SELECT ?, ?, ?, ?, ?, ? WHERE EXISTS" - ) - ) { - const [ - id, - developer_id, - claimant_id, - note, - github_org_verified, - github_verification_note, - checkDeveloperId, - checkClaimantId - ] = p; - const developer = this.tables.developers.get(String(checkDeveloperId)); - const claimantOwnsAny = [...this.tables.developers.values()].some( - (r) => r.owner_user_id === checkClaimantId - ); - if (!developer || developer.owner_user_id !== null || claimantOwnsAny) { - this.changes = 0; - return []; - } - - const pendingExists = [...this.tables.developer_claims.values()].some( - (r) => - r.developer_id === developer_id && - r.claimant_id === claimant_id && - r.status === "pending" - ); - if (pendingExists) { - throw new Error( - "D1_ERROR: UNIQUE constraint failed: developer_claims.developer_id, developer_claims.claimant_id" - ); - } - const now = new Date().toISOString(); - this.tables.developer_claims.set(String(id), { - id, - developer_id, - claimant_id, - status: "pending", - note, - review_note: null, - reviewer_id: null, - created_at: now, - reviewed_at: null, - github_org_verified, - github_verification_note - }); - this.changes = 1; - return []; - } - - if (q.startsWith("SELECT * FROM developer_claims WHERE id = ?")) { - const row = this.tables.developer_claims.get(String(p[0])); - return row ? [row] : []; - } - - if ( - q.startsWith( - "SELECT * FROM developer_claims WHERE claimant_id = ? ORDER BY created_at DESC" - ) - ) { - return [...this.tables.developer_claims.values()] - .filter((r) => r.claimant_id === p[0]) - .sort((a, b) => - String(b.created_at).localeCompare(String(a.created_at)) - ); - } - - if ( - q.startsWith( - "SELECT c.*, d.name AS developer_name, d.type AS developer_type" - ) - ) { - return [...this.tables.developer_claims.values()] - .filter((r) => r.status === "pending") - .sort((a, b) => - String(a.created_at).localeCompare(String(b.created_at)) - ) - .map((r) => { - const developer = this.tables.developers.get(String(r.developer_id)); - return { - ...r, - developer_name: developer?.name ?? "", - developer_type: developer?.type ?? "user" - }; - }); - } - - if ( - q.startsWith( - "UPDATE developer_claims SET status = 'approved', reviewer_id = ?, reviewed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" - ) - ) { - const [reviewer_id, id] = p; - const row = this.tables.developer_claims.get(String(id)); - if (row && row.status === "pending") { - row.status = "approved"; - row.reviewer_id = reviewer_id; - row.reviewed_at = new Date().toISOString(); - this.changes = 1; - } else { - this.changes = 0; - } - return []; - } - - if ( - q.startsWith("UPDATE developers SET owner_user_id = ?") && - q.includes("WHERE id = ? AND owner_user_id IS NULL") - ) { - const [owner_user_id, id] = p; - const row = this.tables.developers.get(String(id)); - if (row && row.owner_user_id === null) { - row.owner_user_id = owner_user_id; - row.ownership_epoch = Number(row.ownership_epoch ?? 1) + 1; - row.content_revision = Number(row.content_revision ?? 1) + 1; - row.approved_at = null; - row.approved_revision = null; - row.approved_by = null; - row.updated_at = new Date().toISOString(); - this.changes = 1; - } else { - this.changes = 0; - } - return []; - } - - if ( - q.startsWith( - "UPDATE developer_claims SET status = 'rejected', reviewer_id = ?, reviewed_at = CURRENT_TIMESTAMP, review_note = 'Another claim on this profile was approved' WHERE changes() = 1 AND developer_id = ?" - ) - ) { - const [reviewer_id, developer_id, excludeId] = p; - const now = new Date().toISOString(); - for (const row of this.tables.developer_claims.values()) { - if ( - row.developer_id === developer_id && - row.status === "pending" && - row.id !== excludeId - ) { - row.status = "rejected"; - row.reviewer_id = reviewer_id; - row.reviewed_at = now; - row.review_note = "Another claim on this profile was approved"; - } - } - return []; - } - - if ( - q.startsWith( - "UPDATE developer_claims SET status = 'pending', reviewer_id = NULL, reviewed_at = NULL WHERE id = ?" - ) - ) { - const [id] = p; - const row = this.tables.developer_claims.get(String(id)); - if (row) { - row.status = "pending"; - row.reviewer_id = null; - row.reviewed_at = null; - } - return []; - } - - if ( - q.startsWith( - "UPDATE developer_claims SET status = 'rejected', reviewer_id = ?, review_note = ?, reviewed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" - ) - ) { - const [reviewer_id, review_note, id] = p; - const row = this.tables.developer_claims.get(String(id)); - if (row && row.status === "pending") { - row.status = "rejected"; - row.reviewer_id = reviewer_id; - row.review_note = review_note; - row.reviewed_at = new Date().toISOString(); - this.changes = 1; - } else { - this.changes = 0; - } - return []; - } - - if (q.startsWith("SELECT is_moderator FROM users WHERE id = ?")) { - const row = this.tables.users.get(String(p[0])); - return row ? [row] : []; - } - - if ( - q.startsWith("SELECT github_login, github_orgs FROM users WHERE id = ?") - ) { - if (this.tables.forceGithubIdentityLookupFailure) { - throw new Error("D1_ERROR: simulated database failure"); - } - const row = this.tables.users.get(String(p[0])); - return row ? [row] : []; - } - - if ( - q.startsWith( - "INSERT INTO extension_submissions (id, extension_id, developer_id, submitted_by, status, payload, ownership_epoch, target_key)" - ) - ) { - const [ - id, - extension_id, - developer_id, - submitted_by, - payload, - target_key, - checkDeveloperId, - checkOwner, - checkEpoch - ] = p; - const developer = this.tables.developers.get(String(checkDeveloperId)); - const allPending = [...this.tables.extension_submissions.values()].filter( - (row) => row.status === "pending" - ); - const submitterPending = allPending.filter( - (row) => row.submitted_by === submitted_by - ); - if ( - !developer || - developer.owner_user_id !== checkOwner || - Number(developer.ownership_epoch ?? 1) !== checkEpoch || - submitterPending.length >= 10 || - allPending.some( - (row) => row.target_key === String(target_key).toLowerCase() - ) - ) { - this.changes = 0; - return []; - } - this.tables.extension_submissions.set(String(id), { - id, - extension_id, - developer_id, - submitted_by, - status: "pending", - payload, - ownership_epoch: Number(developer.ownership_epoch ?? 1), - target_key: String(target_key).toLowerCase(), - reviewer_id: null, - review_note: null, - created_at: new Date().toISOString(), - reviewed_at: null - }); - this.changes = 1; - return []; - } - - if ( - q.startsWith("SELECT * FROM extension_submissions WHERE submitted_by = ?") - ) { - let rows = [...this.tables.extension_submissions.values()] - .filter((r) => r.submitted_by === p[0]) - .sort((a, b) => { - const byDate = String(b.created_at).localeCompare( - String(a.created_at) - ); - return byDate || String(b.id).localeCompare(String(a.id)); - }); - if (q.includes("created_at < ?")) { - const [, createdAt, , id] = p; - rows = rows.filter( - (r) => - String(r.created_at) < String(createdAt) || - (r.created_at === createdAt && String(r.id) < String(id)) - ); - } - return rows.slice(0, Number(p.at(-1))); - } - - if (q.startsWith("SELECT * FROM extension_submissions WHERE status = ?")) { - let rows = [...this.tables.extension_submissions.values()] - .filter((r) => r.status === p[0]) - .sort((a, b) => { - const byDate = String(a.created_at).localeCompare( - String(b.created_at) - ); - return byDate || String(a.id).localeCompare(String(b.id)); - }); - if (q.includes("created_at > ?")) { - const [, createdAt, , id] = p; - rows = rows.filter( - (r) => - String(r.created_at) > String(createdAt) || - (r.created_at === createdAt && String(r.id) > String(id)) - ); - } - return rows.slice(0, Number(p.at(-1))); - } - - if (q.startsWith("SELECT * FROM extension_submissions WHERE id = ?")) { - const row = this.tables.extension_submissions.get(String(p[0])); - return row ? [row] : []; - } - - if ( - q.startsWith("UPDATE extension_submissions SET status = 'rejected'") && - q.includes("Ownership changed before review") - ) { - const [token_hash, accepted_by] = p; - const transfer = [...this.tables.developer_transfers.values()].find( - (r) => - r.token_hash === token_hash && - r.accepted_by === accepted_by && - r.accepted_at !== null - ); - let changes = 0; - for (const row of this.tables.extension_submissions.values()) { - if ( - transfer && - row.developer_id === transfer.developer_id && - row.status === "pending" - ) { - row.status = "rejected"; - row.review_note = "Ownership changed before review"; - row.reviewed_at = new Date().toISOString(); - changes++; - } - } - this.changes = changes; - return []; - } - - if (q.startsWith("UPDATE extension_submissions SET status = 'rejected'")) { - const [reviewer_id, review_note, id] = p; - const row = this.tables.extension_submissions.get(String(id)); - if (row && row.status === "pending") { - row.status = "rejected"; - row.reviewer_id = reviewer_id; - row.review_note = review_note; - row.reviewed_at = new Date().toISOString(); - this.changes = 1; - } - return []; - } - - if (q.startsWith("UPDATE extension_submissions SET status = 'approved'")) { - const [reviewer_id, review_note, id] = p; - const row = this.tables.extension_submissions.get(String(id)); - if ( - this.tables.raceOwnerChangeOnSubmissionApprovalTo !== undefined && - row - ) { - const developer = this.tables.developers.get(String(row.developer_id)); - if (developer) { - developer.owner_user_id = - this.tables.raceOwnerChangeOnSubmissionApprovalTo; - developer.ownership_epoch = - Number(developer.ownership_epoch ?? 1) + 1; - } - this.tables.raceOwnerChangeOnSubmissionApprovalTo = undefined; - } - const developer = row - ? this.tables.developers.get(String(row.developer_id)) - : undefined; - if ( - row && - row.status === "pending" && - developer?.owner_user_id === row.submitted_by && - Number(developer?.ownership_epoch ?? 1) === - Number(row.ownership_epoch ?? 1) - ) { - row.status = "approved"; - row.reviewer_id = reviewer_id; - row.review_note = review_note; - row.reviewed_at = new Date().toISOString(); - this.changes = 1; - } - return []; - } - - if (q.startsWith("UPDATE extension_submissions SET status = 'pending'")) { - const [id] = p; - const row = this.tables.extension_submissions.get(String(id)); - if (row) { - row.status = "pending"; - row.reviewer_id = null; - row.review_note = null; - row.reviewed_at = null; - } - return []; - } - - if ( - q.startsWith( - "INSERT INTO developers (id, type, name, url, owner_user_id)" - ) - ) { - const [id, type, name, url, owner_user_id] = p; - const existing = this.tables.developers.get(String(id)); - this.tables.developers.set(String(id), { - id, - type, - name, - url, - owner_user_id: existing ? existing.owner_user_id : owner_user_id - }); - return []; - } - - if ( - q.startsWith( - "INSERT INTO extensions (id, type, author_id, name, description, releases, website, license, icon_url, readme, source, version, download_url)" - ) - ) { - if (this.tables.forceExtensionWriteFailure) { - throw new Error("simulated write-through failure"); - } - const [ - id, - type, - author_id, - name, - description, - releases, - website, - license, - icon_url, - readme, - source, - version, - download_url - ] = p; - this.tables.extensions.set(String(id), { - id, - type, - author_id, - name, - description, - releases, - website, - license, - icon_url, - readme, - source, - version, - download_url - }); - return []; - } - - // These three mirror deleteOwn()'s guarded deletes: eligibility (still - // owned by the given user, no extensions, no pending submission) is - // re-checked per statement, same as the real correlated-subquery SQL, - // so a stale/raced caller sees all three affect zero rows here too. - if ( - q.startsWith("DELETE FROM developer_transfers WHERE developer_id = ?") - ) { - const [developer_id, owner_user_id] = p; - let changes = 0; - if ( - this.isEligibleForDeveloperDeletion(String(developer_id), owner_user_id) - ) { - for (const [key, row] of this.tables.developer_transfers) { - if (row.developer_id === developer_id) { - this.tables.developer_transfers.delete(key); - changes++; - } - } - } - this.changes = changes; - return []; - } - - if (q.startsWith("DELETE FROM developer_claims WHERE id = ?")) { - const [id, claimant_id] = p; - const row = this.tables.developer_claims.get(String(id)); - const eligible = - !!row && row.claimant_id === claimant_id && row.status === "pending"; - if (eligible) this.tables.developer_claims.delete(String(id)); - this.changes = eligible ? 1 : 0; - return []; - } - - if (q.startsWith("DELETE FROM developer_claims WHERE developer_id = ?")) { - const [developer_id, owner_user_id] = p; - let changes = 0; - if ( - this.isEligibleForDeveloperDeletion(String(developer_id), owner_user_id) - ) { - for (const [key, row] of this.tables.developer_claims) { - if (row.developer_id === developer_id) { - this.tables.developer_claims.delete(key); - changes++; - } - } - } - this.changes = changes; - return []; - } - - if (q.startsWith("DELETE FROM developers WHERE id = ?")) { - const [id, owner_user_id] = p; - const eligible = this.isEligibleForDeveloperDeletion( - String(id), - owner_user_id - ); - this.changes = - eligible && this.tables.developers.delete(String(id)) ? 1 : 0; - return []; - } - - throw new Error(`MockStatement: unhandled query: ${q}`); - } - - async all(): Promise> { - return ok(this.execute() as unknown as T[]); - } - - async first(column?: string): Promise { - const rows = this.execute(); - if (rows.length === 0) return null; - if (column) return (rows[0][column] as T) ?? null; - return rows[0] as unknown as T; - } - - async run>(): Promise> { - this.execute(); - return ok([], this.changes); - } -} - -export function createMockD1(tables: MockTables): D1Database { - return { - prepare(query: string): D1PreparedStatement { - return new MockStatement(tables, query); - }, - - dump(): Promise { - throw new Error("not implemented"); - }, - - async batch( - statements: D1PreparedStatement[] - ): Promise[]> { - const snapshots = { - developers: structuredClone(tables.developers), - extensions: structuredClone(tables.extensions), - extension_submissions: structuredClone(tables.extension_submissions), - developer_history: structuredClone(tables.developer_history), - developer_transfers: structuredClone(tables.developer_transfers), - developer_claims: structuredClone(tables.developer_claims), - users: structuredClone(tables.users) - }; - const results: D1Result[] = []; - // Mirrors SQL's changes(): the mock has no other way to expose "how - // many rows did the immediately preceding statement change" across - // statements, but acceptTransfer's ownership UPDATE deliberately - // gates on changes() = 1 to prove the just-run claim actually fired - // (rather than the token having been accepted at some point in the - // past) — a statement referencing that gate is skipped here unless - // the previous statement's change count was exactly 1. - let lastChanges = 0; - try { - for (const stmt of statements) { - const query = - stmt instanceof MockStatement ? stmt.normalizedQuery : ""; - if (query.includes("WHERE changes() = 1") && lastChanges !== 1) { - results.push(ok([], 0)); - continue; - } - const result = await stmt.run(); - lastChanges = result.meta?.changes ?? 0; - results.push(result); - } - } catch (error) { - tables.developers = snapshots.developers; - tables.extensions = snapshots.extensions; - tables.extension_submissions = snapshots.extension_submissions; - tables.developer_history = snapshots.developer_history; - tables.developer_transfers = snapshots.developer_transfers; - tables.developer_claims = snapshots.developer_claims; - tables.users = snapshots.users; - throw error; - } - return results; - }, - - exec(_query: string): Promise { - throw new Error("not implemented"); - }, - - withSession(_constraintOrBookmark?: string): D1DatabaseSession { - throw new Error("not implemented"); - } - }; -} diff --git a/test/services/versions/v1/middleware.test.ts b/test/services/versions/v1/middleware.test.ts index a21b2b5..dcbcbff 100644 --- a/test/services/versions/v1/middleware.test.ts +++ b/test/services/versions/v1/middleware.test.ts @@ -13,7 +13,10 @@ import { suppressConsole, setupGitHubApiMock } from "../../../utils/mock-helpers"; -import { MockGitHubGraphQL, MockGitHubRequest } from "../../../utils/test-types"; +import { + MockGitHubGraphQL, + MockGitHubRequest +} from "../../../utils/test-types"; vi.mock("@octokit/request", () => { const endpoint = { DEFAULTS: {} }; diff --git a/test/utils/apply-migrations.ts b/test/utils/apply-migrations.ts new file mode 100644 index 0000000..e46078d --- /dev/null +++ b/test/utils/apply-migrations.ts @@ -0,0 +1,35 @@ +import { applyD1Migrations, env } from "cloudflare:test"; + +// Call from a test file's own beforeAll (not vitest.config.ts's +// setupFiles - see the comment in vitest.config.ts for why). applyD1Migrations +// records applied migrations in a bookkeeping table, so calling this +// redundantly across multiple beforeAll hooks in the same test file is +// harmless. +let applied = false; + +async function runStatements(db: D1Database, sql: string): Promise { + const statements = sql + .split(";") + .map((s) => s.trim()) + .filter(Boolean); + for (const statement of statements) { + await db.prepare(statement).run(); + } +} + +export async function applyTestMigrations(): Promise { + if (applied) return; + applied = true; + + // v1's schema.sql and the users stub must exist before the v2 migrations + // list runs (see vitest.config.ts for why these are string bindings + // rather than files read here). + await runStatements(env.DB_EXTENSIONS, env.TEST_V1_SCHEMA_SQL); + await runStatements(env.DB_EXTENSIONS, env.TEST_USERS_STUB_SQL); + + await applyD1Migrations(env.DB_EXTENSIONS, env.TEST_MIGRATIONS_EXTENSIONS); + await applyD1Migrations( + env.DB_CENTRAL_ALERTS, + env.TEST_MIGRATIONS_CENTRAL_ALERTS + ); +} diff --git a/test/utils/d1-mock.ts b/test/utils/d1-mock.ts deleted file mode 100644 index 661e770..0000000 --- a/test/utils/d1-mock.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Mock D1 Database for testing - * Implements the D1Database interface with in-memory storage - */ -import { CentralAlert } from "../../src/services/central-alerts/v1/interfaces"; - -type DatabaseAlert = Omit & { - created_at: string; - updated_at: string; - buttons?: string; // JSON string in database -}; - -/** - * Compare two semantic version strings (e.g. "1.2.3") numerically. - * Returns -1 if a < b, 0 if a == b, 1 if a > b. - */ -function compareVersions(a: string, b: string): number { - const safeParseInt = (part: string): number => { - const trimmed = part.trim(); - if (trimmed === "") { - return 0; - } - const parsed = parseInt(trimmed, 10); - return Number.isNaN(parsed) ? 0 : parsed; - }; - - const aParts = a.split(".").map((part) => safeParseInt(part)); - const bParts = b.split(".").map((part) => safeParseInt(part)); - const length = Math.max(aParts.length, bParts.length); - - for (let i = 0; i < length; i++) { - const aNum = aParts[i] ?? 0; - const bNum = bParts[i] ?? 0; - - if (aNum < bNum) { - return -1; - } - if (aNum > bNum) { - return 1; - } - } - - return 0; -} - -/** - * Check whether `version` is within [minVersion, maxVersion] inclusive - * using semantic version comparison. Only simple numeric versions (x.y.z) - * are supported; pre-release or build metadata suffixes are not handled. - */ -function isVersionInRange( - version: string, - minVersion: string, - maxVersion: string -): boolean { - return ( - compareVersions(version, minVersion) >= 0 && - compareVersions(version, maxVersion) <= 0 - ); -} - -function notImplementedInMock(methodName: string): Error { - return new Error( - `MockD1Database.${methodName}() is not implemented for tests` - ); -} - -export class MockD1Database implements D1Database { - private alerts: DatabaseAlert[] = []; - - constructor() { - // Initialize with test data - this.alerts = [ - { - id: "1", - title: "SQL Injection Vulnerability", - message: - "A critical SQL injection vulnerability has been discovered. Please update immediately.", - type: "danger", - dismissible: false, - min_fossbilling_version: "0.0.0", - max_fossbilling_version: "0.5.2", - include_preview_branch: false, - buttons: - '[{"text":"Security Advisory","link":"https://fossbilling.org/security-advisory","type":"danger"}]', - datetime: "2023-01-15T10:30:00Z", - created_at: "2023-01-15T10:30:00Z", - updated_at: "2023-01-15T10:30:00Z" - } - ]; - } - - prepare(query: string): D1PreparedStatement { - return { - bind: (...params: unknown[]) => { - return { - all: async (): Promise> => { - // Simulate getting alerts by version - if ( - query.includes("min_fossbilling_version") && - query.includes("max_fossbilling_version") - ) { - const version = params[0] as string; - const filteredAlerts = this.alerts.filter((alert) => { - // Check if it's the universal alert (applies to all versions) - if ( - alert.min_fossbilling_version === "0.0.0" && - alert.max_fossbilling_version === "9999.9999.9999" - ) { - return true; - } - // For version comparison, we need to check if version is within the alert's range - return isVersionInRange( - version, - alert.min_fossbilling_version, - alert.max_fossbilling_version - ); - }); - return { - success: true, - results: filteredAlerts, - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; - } - - // Simulate getting alert by ID - if (query.includes("WHERE id = ?")) { - const id = String(params[0]); - const alert = this.alerts.find((a) => a.id === id); - return { - success: true, - results: alert ? [alert] : [], - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; - } - - return { - success: true, - results: [], - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; - }, - - first: async >(): Promise => { - // Simulate getting first result (for getAlertById) - if (query.includes("WHERE id = ?")) { - const id = params[0] as string; - const alert = this.alerts.find((a) => a.id === id); - return (alert as T | undefined) || null; - } - - return null; - }, - - run: async (): Promise => { - // Simulate INSERT operations - if (query.includes("INSERT INTO central_alerts")) { - const newAlert: DatabaseAlert = { - id: params[0] as string, - title: params[1] as string, - message: params[2] as string, - type: params[3] as "info" | "warning" | "danger" | "success", - dismissible: params[4] as boolean, - min_fossbilling_version: params[5] as string, - max_fossbilling_version: params[6] as string, - include_preview_branch: params[7] as boolean, - datetime: params[8] as string, - buttons: params[9] as string, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }; - this.alerts.push(newAlert); - return { - success: true, - meta: { - duration: 0, - last_row_id: this.alerts.length, - changes: 1, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 1, - changed_db: true - } - }; - } - - // Simulate DELETE operations - if (query.includes("DELETE FROM central_alerts")) { - const id = params[0] as string; - const initialLength = this.alerts.length; - this.alerts = this.alerts.filter((a) => a.id !== id); - const deletedCount = initialLength - this.alerts.length; - const wasDeleted = deletedCount > 0; - - return { - success: true, - meta: { - duration: 0, - last_row_id: 0, - changes: deletedCount, - served_by: "mock", - size_after: 0, - rows_read: 1, - rows_written: deletedCount, - changed_db: wasDeleted - } - }; - } - - return { - success: true, - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; - } - }; - }, - - all: async (): Promise> => { - // Simulate getting all alerts - if ( - query.includes("FROM central_alerts") && - (query.includes("ORDER BY datetime DESC") || - query.includes('ORDER BY "datetime" DESC')) - ) { - return { - success: true, - results: this.alerts, - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; - } - - return { - success: true, - results: [], - meta: { - duration: 0, - last_row_id: 0, - changes: 0, - served_by: "mock", - size_after: 0, - rows_read: 0, - rows_written: 0, - changed_db: false - } - }; - }, - - first: async >(): Promise => { - // Simulate getting first result (for getAlertById without bind) - if (query.includes("WHERE id = ?")) { - // This shouldn't be called without bind, but handle it - return null; - } - - return null; - } - } as D1PreparedStatement; - } - - dump(): Promise { - throw notImplementedInMock("dump"); - } - - batch( - _statements: D1PreparedStatement[] - ): Promise[]> { - throw notImplementedInMock("batch"); - } - - exec( - _query: string - ): Promise { - throw notImplementedInMock("exec"); - } - - withSession( - _constraintOrBookmark?: string - ): D1DatabaseSession { - throw notImplementedInMock("withSession"); - } -} - -// Create a singleton instance -export const mockD1Database = new MockD1Database(); diff --git a/vitest.config.ts b/vitest.config.ts index 5e23548..07d16bd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,39 @@ -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + cloudflareTest, + readD1Migrations +} from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; +// Read migrations at config time (Node) so they can be applied inside the +// Workers runtime by test/apply-migrations.ts (a setupFiles script) - +// Miniflare provisions the D1 bindings declared in wrangler.jsonc but +// doesn't run their migrations_dir automatically. Top-level await (Vite +// config files are ESM) avoids fighting defineConfig's overload typing for +// an async factory function. +const extensionsMigrations = await readD1Migrations( + path.join(__dirname, "src/services/extensions/v2/db/migrations") +); +const centralAlertsMigrations = await readD1Migrations( + path.join(__dirname, "src/services/central-alerts/v1/db/migrations") +); + +// v1's schema.sql (authors/extensions) and the sibling FOSSBilling/extensions +// repo's `users` table are bootstrapped by hand in every real environment +// (never through `wrangler d1 migrations`), so readD1Migrations never sees +// them - migration 0001 ALTERs `authors` and several v2 tables REFERENCE +// `users(id)`, so both must exist before the v2 migrations run against a +// fresh test-local D1. Read as a string here (Node) rather than having +// test/apply-migrations.ts read the file itself - that script runs inside +// workerd, which doesn't have host filesystem access via node:fs. +const v1SchemaSql = readFileSync( + path.join(__dirname, "src/services/extensions/v1/db/schema.sql"), + "utf8" +); +const usersStubSql = + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY NOT NULL, is_moderator INTEGER, github_login TEXT, github_orgs TEXT);"; + export default defineConfig({ plugins: [ cloudflareTest({ @@ -10,12 +43,27 @@ export default defineConfig({ bindings: { GITHUB_TOKEN: "test-github-token", UPDATE_TOKEN: "test-update-token", - ASSERTION_SIGNING_SECRET: "test-assertion-signing-secret" + ASSERTION_SIGNING_SECRET: "test-assertion-signing-secret", + TEST_MIGRATIONS_EXTENSIONS: extensionsMigrations, + TEST_MIGRATIONS_CENTRAL_ALERTS: centralAlertsMigrations, + TEST_V1_SCHEMA_SQL: v1SchemaSql, + TEST_USERS_STUB_SQL: usersStubSql } } }) ], test: { + // Deliberately NOT using `setupFiles` for migrations: importing + // anything from "cloudflare:test" inside a setupFiles script breaks + // vi.mock() for unrelated test files in this version of + // @cloudflare/vitest-pool-workers (confirmed empirically - even an + // unused import of createExecutionContext there made GitHub API mocks + // in unrelated versions/v1 tests fall through to real network calls). + // Instead, test/utils/apply-migrations.ts exports a function each test + // file that touches DB_EXTENSIONS/DB_CENTRAL_ALERTS calls itself from + // its own beforeAll, which works fine since the import then lives in + // the test file's own module graph rather than a separate setupFiles one. + // Exclude Node.js tests from Cloudflare Workers environment exclude: ["**/node_modules/**", "**/test/lib/adapters/node/**"], diff --git a/wrangler.jsonc b/wrangler.jsonc index 9fb548f..8d953bb 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -28,7 +28,8 @@ "d1_databases": [ { "binding": "DB_CENTRAL_ALERTS", - "database_id": "3a47f7fd-5eda-4754-9594-9de809ad7823" + "database_id": "3a47f7fd-5eda-4754-9594-9de809ad7823", + "migrations_dir": "src/services/central-alerts/v1/db/migrations" }, { "binding": "DB_EXTENSIONS",