examples/storefront Public
Start the storefront API with a product catalog on D1
Products are listed by name, read one at a time, and written by the merchant through PUT /admin/products/:id with the admin token. Tests run the real migrations against node:sqlite.
Changed files
17 files changed958 additions0 deletionsin the first commit
- Added.gitignore+3−0
- AddedLICENSE+21−0
- AddedREADME.md+8−0
- Addedmigrations/0001_products.sql+14−0
- Addedpackage-lock.json+420−0
- Addedpackage.json+22−0
- Addedsrc/app.ts+49−0
- Addedsrc/catalog.ts+96−0
- Addedsrc/env.ts+5−0
- Addedsrc/http.ts+64−0
- Addedsrc/index.ts+8−0
- Addedsrc/router.ts+50−0
- Addedtest/catalog.test.ts+56−0
- Addedtest/helpers/app.ts+35−0
- Addedtest/helpers/d1.ts+74−0
- Addedtsconfig.json+16−0
- Addedwrangler.jsonc+17−0
Added.gitignore+3−0
| @@ -0,0 +1,3 @@ | ||
| +node_modules/ | ||
| +.wrangler/ | ||
| +.dev.vars | ||
AddedLICENSE+21−0
| @@ -0,0 +1,21 @@ | ||
| +MIT License | ||
| + | ||
| +Copyright (c) 2026 Justin Torre | ||
| + | ||
| +Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| +of this software and associated documentation files (the "Software"), to deal | ||
| +in the Software without restriction, including without limitation the rights | ||
| +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| +copies of the Software, and to permit persons to whom the Software is | ||
| +furnished to do so, subject to the following conditions: | ||
| + | ||
| +The above copyright notice and this permission notice shall be included in all | ||
| +copies or substantial portions of the Software. | ||
| + | ||
| +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| +SOFTWARE. | ||
AddedREADME.md+8−0
| @@ -0,0 +1,8 @@ | ||
| +# storefront | ||
| + | ||
| +A storefront API on Cloudflare Workers. Products live in D1. | ||
| + | ||
| +```bash | ||
| +npm install | ||
| +npm test | ||
| +``` | ||
Addedmigrations/0001_products.sql+14−0
| @@ -0,0 +1,14 @@ | ||
| +-- Products: the catalog. Prices are whole cents in the shop's one currency (src/money.ts). | ||
| +CREATE TABLE products ( | ||
| + id TEXT PRIMARY KEY, | ||
| + name TEXT NOT NULL, | ||
| + description TEXT NOT NULL DEFAULT '', | ||
| + price_cents INTEGER NOT NULL CHECK (price_cents > 0), | ||
| + stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), | ||
| + image_key TEXT, | ||
| + active INTEGER NOT NULL DEFAULT 1, | ||
| + created_at INTEGER NOT NULL, | ||
| + updated_at INTEGER NOT NULL | ||
| +); | ||
| + | ||
| +CREATE INDEX products_by_name ON products (active, name, id); | ||
Addedpackage-lock.jsonLong diff, collapsed+420−0
| @@ -0,0 +1,420 @@ | ||
| +{ | ||
| + "name": "storefront", | ||
| + "version": "0.1.0", | ||
| + "lockfileVersion": 3, | ||
| + "requires": true, | ||
| + "packages": { | ||
| + "": { | ||
| + "name": "storefront", | ||
| + "version": "0.1.0", | ||
| + "license": "MIT", | ||
| + "devDependencies": { | ||
| + "@cloudflare/workers-types": "^5.20261016.1", | ||
| + "@types/node": "^24.19.2", | ||
| + "typescript": "^7.0.2" | ||
| + }, | ||
| + "engines": { | ||
| + "node": ">=24" | ||
| + } | ||
| + }, | ||
| + "node_modules/@cloudflare/workers-types": { | ||
| + "version": "5.20261016.1", | ||
| + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261016.1.tgz", | ||
| + "integrity": "sha512-I8OJdPl3M6TIl9ksU3wtEyWVhSE8E8txJ/vGOv3SkhjcvN8uMjyFmMvRsNOZgzMe5xYFQ0LctmiGqTwR7DbZ6w==", | ||
| + "dev": true, | ||
| + "license": "MIT OR Apache-2.0" | ||
| + }, | ||
| + "node_modules/@types/node": { | ||
| + "version": "24.19.2", | ||
| + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", | ||
| + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", | ||
| + "dev": true, | ||
| + "license": "MIT", | ||
| + "dependencies": { | ||
| + "undici-types": ">=7.24.0 <7.24.7" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-aix-ppc64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", | ||
| + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", | ||
| + "cpu": [ | ||
| + "ppc64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "aix" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-darwin-arm64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", | ||
| + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", | ||
| + "cpu": [ | ||
| + "arm64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "darwin" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-darwin-x64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", | ||
| + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", | ||
| + "cpu": [ | ||
| + "x64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "darwin" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-freebsd-arm64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", | ||
| + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", | ||
| + "cpu": [ | ||
| + "arm64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "freebsd" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-freebsd-x64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", | ||
| + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", | ||
| + "cpu": [ | ||
| + "x64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "freebsd" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-arm": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", | ||
| + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", | ||
| + "cpu": [ | ||
| + "arm" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-arm64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", | ||
| + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", | ||
| + "cpu": [ | ||
| + "arm64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-loong64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", | ||
| + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", | ||
| + "cpu": [ | ||
| + "loong64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-mips64el": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", | ||
| + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", | ||
| + "cpu": [ | ||
| + "mips64el" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-ppc64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", | ||
| + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", | ||
| + "cpu": [ | ||
| + "ppc64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-riscv64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", | ||
| + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", | ||
| + "cpu": [ | ||
| + "riscv64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-s390x": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", | ||
| + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", | ||
| + "cpu": [ | ||
| + "s390x" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-linux-x64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", | ||
| + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", | ||
| + "cpu": [ | ||
| + "x64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "linux" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-netbsd-arm64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", | ||
| + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", | ||
| + "cpu": [ | ||
| + "arm64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "netbsd" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-netbsd-x64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", | ||
| + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", | ||
| + "cpu": [ | ||
| + "x64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "netbsd" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-openbsd-arm64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", | ||
| + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", | ||
| + "cpu": [ | ||
| + "arm64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "openbsd" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-openbsd-x64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", | ||
| + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", | ||
| + "cpu": [ | ||
| + "x64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "openbsd" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-sunos-x64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", | ||
| + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", | ||
| + "cpu": [ | ||
| + "x64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "sunos" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-win32-arm64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", | ||
| + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", | ||
| + "cpu": [ | ||
| + "arm64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "win32" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/@typescript/typescript-win32-x64": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", | ||
| + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", | ||
| + "cpu": [ | ||
| + "x64" | ||
| + ], | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "optional": true, | ||
| + "os": [ | ||
| + "win32" | ||
| + ], | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + } | ||
| + }, | ||
| + "node_modules/typescript": { | ||
| + "version": "7.0.2", | ||
| + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", | ||
| + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", | ||
| + "dev": true, | ||
| + "license": "Apache-2.0", | ||
| + "bin": { | ||
| + "tsc": "bin/tsc" | ||
| + }, | ||
| + "engines": { | ||
| + "node": ">=16.20.0" | ||
| + }, | ||
| + "optionalDependencies": { | ||
| + "@typescript/typescript-aix-ppc64": "7.0.2", | ||
| + "@typescript/typescript-darwin-arm64": "7.0.2", | ||
| + "@typescript/typescript-darwin-x64": "7.0.2", | ||
| + "@typescript/typescript-freebsd-arm64": "7.0.2", | ||
| + "@typescript/typescript-freebsd-x64": "7.0.2", | ||
| + "@typescript/typescript-linux-arm": "7.0.2", | ||
| + "@typescript/typescript-linux-arm64": "7.0.2", | ||
| + "@typescript/typescript-linux-loong64": "7.0.2", | ||
| + "@typescript/typescript-linux-mips64el": "7.0.2", | ||
| + "@typescript/typescript-linux-ppc64": "7.0.2", | ||
| + "@typescript/typescript-linux-riscv64": "7.0.2", | ||
| + "@typescript/typescript-linux-s390x": "7.0.2", | ||
| + "@typescript/typescript-linux-x64": "7.0.2", | ||
| + "@typescript/typescript-netbsd-arm64": "7.0.2", | ||
| + "@typescript/typescript-netbsd-x64": "7.0.2", | ||
| + "@typescript/typescript-openbsd-arm64": "7.0.2", | ||
| + "@typescript/typescript-openbsd-x64": "7.0.2", | ||
| + "@typescript/typescript-sunos-x64": "7.0.2", | ||
| + "@typescript/typescript-win32-arm64": "7.0.2", | ||
| + "@typescript/typescript-win32-x64": "7.0.2" | ||
| + } | ||
| + }, | ||
| + "node_modules/undici-types": { | ||
| + "version": "7.24.6", | ||
| + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", | ||
| + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", | ||
| + "dev": true, | ||
| + "license": "MIT" | ||
| + } | ||
| + } | ||
| +} | ||
Addedpackage.json+22−0
| @@ -0,0 +1,22 @@ | ||
| +{ | ||
| + "name": "storefront", | ||
| + "version": "0.1.0", | ||
| + "private": true, | ||
| + "description": "A storefront API on Cloudflare Workers: products, carts and checkout on D1.", | ||
| + "license": "MIT", | ||
| + "type": "module", | ||
| + "engines": { | ||
| + "node": ">=24" | ||
| + }, | ||
| + "scripts": { | ||
| + "test": "node --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"", | ||
| + "typecheck": "tsc", | ||
| + "dev": "npx wrangler dev", | ||
| + "db:migrate": "npx wrangler d1 migrations apply storefront --local" | ||
| + }, | ||
| + "devDependencies": { | ||
| + "@cloudflare/workers-types": "^5.20261016.1", | ||
| + "@types/node": "^24.19.2", | ||
| + "typescript": "^7.0.2" | ||
| + } | ||
| +} | ||
Addedsrc/app.ts+49−0
| @@ -0,0 +1,49 @@ | ||
| +// The storefront's HTTP API. createApp takes the environment and the few things tests replace | ||
| +// (the clock and id generation) and returns a fetch handler. | ||
| + | ||
| +import { getProduct, listProducts, parseProductInput, upsertProduct } from "./catalog.ts"; | ||
| +import type { Env } from "./env.ts"; | ||
| +import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts"; | ||
| +import { Router } from "./router.ts"; | ||
| + | ||
| +export type Deps = { | ||
| + now: () => number; | ||
| + newId: () => string; | ||
| +}; | ||
| + | ||
| +export const defaultDeps: Deps = { now: Date.now, newId: () => crypto.randomUUID() }; | ||
| + | ||
| +export function createApp(env: Env, deps: Deps = defaultDeps) { | ||
| + const r = new Router(); | ||
| + | ||
| + r.on("GET", "/products", async () => json({ products: await listProducts(env.DB) })); | ||
| + | ||
| + r.on("GET", "/products/:id", async (_req, p) => { | ||
| + const product = await getProduct(env.DB, p.id!); | ||
| + if (!product || !product.active) throw new HttpError(404, "not_found", "No such product."); | ||
| + return json({ product }); | ||
| + }); | ||
| + | ||
| + r.on("PUT", "/admin/products/:id", async (req, p) => { | ||
| + requireAdmin(req, env.ADMIN_TOKEN); | ||
| + const input = parseProductInput(p.id!, await readJson(req)); | ||
| + return json({ product: await upsertProduct(env.DB, input, deps.now()) }); | ||
| + }); | ||
| + | ||
| + return { | ||
| + async fetch(req: Request): Promise<Response> { | ||
| + const url = new URL(req.url); | ||
| + try { | ||
| + const hit = r.match(req.method, url.pathname); | ||
| + if (hit === null) throw new HttpError(404, "not_found", "No such route."); | ||
| + if (hit === "method_not_allowed") throw new HttpError(405, "method_not_allowed", `${req.method} is not allowed here.`); | ||
| + return await hit.handler(req, hit.params); | ||
| + } catch (e) { | ||
| + if (e instanceof HttpError) return errorResponse(e); | ||
| + if (e instanceof URIError) return errorResponse(new HttpError(400, "bad_path", "The path is not valid.")); | ||
| + console.error(JSON.stringify({ msg: "unhandled error", path: url.pathname, error: String(e) })); | ||
| + return errorResponse(new HttpError(500, "internal", "Something went wrong.")); | ||
| + } | ||
| + }, | ||
| + }; | ||
| +} | ||
Addedsrc/catalog.ts+96−0
| @@ -0,0 +1,96 @@ | ||
| +// The product catalog: what the shop sells, at what price, and how many are left. | ||
| + | ||
| +import { HttpError } from "./http.ts"; | ||
| + | ||
| +export type Product = { | ||
| + id: string; | ||
| + name: string; | ||
| + description: string; | ||
| + price_cents: number; | ||
| + stock: number; | ||
| + image_url: string | null; | ||
| + active: boolean; | ||
| +}; | ||
| + | ||
| +type Row = { | ||
| + id: string; | ||
| + name: string; | ||
| + description: string; | ||
| + price_cents: number; | ||
| + stock: number; | ||
| + image_key: string | null; | ||
| + active: number; | ||
| +}; | ||
| + | ||
| +const COLUMNS = "id, name, description, price_cents, stock, image_key, active"; | ||
| +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; | ||
| + | ||
| +export function toProduct(r: Row): Product { | ||
| + return { | ||
| + id: r.id, | ||
| + name: r.name, | ||
| + description: r.description, | ||
| + price_cents: r.price_cents, | ||
| + stock: r.stock, | ||
| + image_url: r.image_key ? `/images/${r.image_key}` : null, | ||
| + active: r.active === 1, | ||
| + }; | ||
| +} | ||
| + | ||
| +// Active products by name. The first 100 for now; a catalog bigger than that needs pages. | ||
| +export async function listProducts(db: D1Database): Promise<Product[]> { | ||
| + const { results } = await db | ||
| + .prepare(`SELECT ${COLUMNS} FROM products WHERE active = 1 ORDER BY name, id LIMIT 100`) | ||
| + .all<Row>(); | ||
| + return results.map(toProduct); | ||
| +} | ||
| + | ||
| +// Any product, active or not. Shopper routes check `active` themselves. | ||
| +export async function getProduct(db: D1Database, id: string): Promise<Product | null> { | ||
| + const row = await db.prepare(`SELECT ${COLUMNS} FROM products WHERE id = ?`).bind(id).first<Row>(); | ||
| + return row ? toProduct(row) : null; | ||
| +} | ||
| + | ||
| +export type ProductInput = { | ||
| + id: string; | ||
| + name: string; | ||
| + description: string; | ||
| + price_cents: number; | ||
| + stock: number; | ||
| + active: boolean; | ||
| +}; | ||
| + | ||
| +export function parseProductInput(id: string, body: Record<string, unknown>): ProductInput { | ||
| + if (!SLUG.test(id) || id.length > 64) { | ||
| + throw new HttpError(400, "bad_id", "A product id is a lowercase slug of at most 64 characters."); | ||
| + } | ||
| + const { name, description = "", price_cents, stock = 0, active = true } = body; | ||
| + if (typeof name !== "string" || !name.trim() || name.length > 120) { | ||
| + throw new HttpError(400, "bad_name", "name is a string of 1 to 120 characters."); | ||
| + } | ||
| + if (typeof description !== "string" || description.length > 2000) { | ||
| + throw new HttpError(400, "bad_description", "description is a string of at most 2,000 characters."); | ||
| + } | ||
| + if (!Number.isInteger(price_cents) || (price_cents as number) <= 0) { | ||
| + throw new HttpError(400, "bad_price", "price_cents is a whole number of cents above zero."); | ||
| + } | ||
| + if (!Number.isInteger(stock) || (stock as number) < 0) { | ||
| + throw new HttpError(400, "bad_stock", "stock is a whole number, zero or more."); | ||
| + } | ||
| + if (typeof active !== "boolean") throw new HttpError(400, "bad_active", "active is true or false."); | ||
| + return { id, name: name.trim(), description, price_cents: price_cents as number, stock: stock as number, active }; | ||
| +} | ||
| + | ||
| +export async function upsertProduct(db: D1Database, p: ProductInput, now: number): Promise<Product> { | ||
| + await db | ||
| + .prepare( | ||
| + `INSERT INTO products (id, name, description, price_cents, stock, active, created_at, updated_at) | ||
| + VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||
| + ON CONFLICT (id) DO UPDATE SET | ||
| + name = excluded.name, description = excluded.description, price_cents = excluded.price_cents, | ||
| + stock = excluded.stock, active = excluded.active, updated_at = excluded.updated_at`, | ||
| + ) | ||
| + .bind(p.id, p.name, p.description, p.price_cents, p.stock, p.active ? 1 : 0, now, now) | ||
| + .run(); | ||
| + return (await getProduct(db, p.id))!; | ||
| +} | ||
Addedsrc/env.ts+5−0
| @@ -0,0 +1,5 @@ | ||
| +// The bindings and secrets of the storefront-api Worker (wrangler.jsonc). | ||
| +export interface Env { | ||
| + DB: D1Database; | ||
| + ADMIN_TOKEN: string; | ||
| +} | ||
Addedsrc/http.ts+64−0
| @@ -0,0 +1,64 @@ | ||
| +// JSON responses and the errors the API answers with. Every error body is | ||
| +// { error, message } plus whatever details the error carries. | ||
| + | ||
| +export class HttpError extends Error { | ||
| + readonly status: number; | ||
| + readonly code: string; | ||
| + readonly details: Record<string, unknown>; | ||
| + | ||
| + constructor(status: number, code: string, message: string, details: Record<string, unknown> = {}) { | ||
| + super(message); | ||
| + this.status = status; | ||
| + this.code = code; | ||
| + this.details = details; | ||
| + } | ||
| +} | ||
| + | ||
| +export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response { | ||
| + return new Response(JSON.stringify(body), { | ||
| + status, | ||
| + headers: { "content-type": "application/json; charset=utf-8", ...headers }, | ||
| + }); | ||
| +} | ||
| + | ||
| +export function errorResponse(e: HttpError): Response { | ||
| + return json({ error: e.code, message: e.message, ...e.details }, e.status); | ||
| +} | ||
| + | ||
| +const MAX_BODY = 64 * 1024; | ||
| + | ||
| +export async function readJson(req: Request): Promise<Record<string, unknown>> { | ||
| + if (!(req.headers.get("content-type") ?? "").startsWith("application/json")) { | ||
| + throw new HttpError(415, "unsupported_media_type", "Send the body as application/json."); | ||
| + } | ||
| + const text = await req.text(); | ||
| + if (text.length > MAX_BODY) throw new HttpError(413, "too_large", "The body is at most 64 KiB."); | ||
| + let value: unknown; | ||
| + try { | ||
| + value = JSON.parse(text); | ||
| + } catch { | ||
| + throw new HttpError(400, "bad_json", "The body is not valid JSON."); | ||
| + } | ||
| + if (!value || typeof value !== "object" || Array.isArray(value)) { | ||
| + throw new HttpError(400, "bad_json", "The body must be a JSON object."); | ||
| + } | ||
| + return value as Record<string, unknown>; | ||
| +} | ||
| + | ||
| +// Merchant routes (/admin/*) take the shop's admin token as a bearer token. | ||
| +export function requireAdmin(req: Request, token: string | undefined): void { | ||
| + const got = req.headers.get("authorization") ?? ""; | ||
| + if (!token || !sameBytes(got, `Bearer ${token}`)) { | ||
| + throw new HttpError(401, "unauthorized", "Merchant routes need the admin token."); | ||
| + } | ||
| +} | ||
| + | ||
| +// Compares in time that depends only on the length, so the token can't be guessed byte by byte. | ||
| +function sameBytes(a: string, b: string): boolean { | ||
| + const x = new TextEncoder().encode(a); | ||
| + const y = new TextEncoder().encode(b); | ||
| + if (x.length !== y.length) return false; | ||
| + let diff = 0; | ||
| + for (let i = 0; i < x.length; i++) diff |= x[i]! ^ y[i]!; | ||
| + return diff === 0; | ||
| +} | ||
Addedsrc/index.ts+8−0
| @@ -0,0 +1,8 @@ | ||
| +import { createApp } from "./app.ts"; | ||
| +import type { Env } from "./env.ts"; | ||
| + | ||
| +export default { | ||
| + fetch(req, env) { | ||
| + return createApp(env).fetch(req); | ||
| + }, | ||
| +} satisfies ExportedHandler<Env>; | ||
Addedsrc/router.ts+50−0
| @@ -0,0 +1,50 @@ | ||
| +// A small method and path router. Patterns use :name for one segment and a final * for the | ||
| +// rest of the path, which arrives as params.rest. | ||
| + | ||
| +export type Params = Record<string, string>; | ||
| +export type Handler = (req: Request, params: Params) => Promise<Response> | Response; | ||
| + | ||
| +type Route = { method: string; re: RegExp; keys: string[]; handler: Handler }; | ||
| + | ||
| +export type Match = { handler: Handler; params: Params } | "method_not_allowed" | null; | ||
| + | ||
| +export class Router { | ||
| + private readonly routes: Route[] = []; | ||
| + | ||
| + on(method: string, pattern: string, handler: Handler): this { | ||
| + const keys: string[] = []; | ||
| + const source = pattern | ||
| + .split("/") | ||
| + .map((seg) => { | ||
| + if (seg === "*") { | ||
| + keys.push("rest"); | ||
| + return "(.+)"; | ||
| + } | ||
| + if (seg.startsWith(":")) { | ||
| + keys.push(seg.slice(1)); | ||
| + return "([^/]+)"; | ||
| + } | ||
| + return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| + }) | ||
| + .join("/"); | ||
| + this.routes.push({ method, re: new RegExp(`^${source}$`), keys, handler }); | ||
| + return this; | ||
| + } | ||
| + | ||
| + // Throws URIError when a segment is not valid percent-encoding. | ||
| + match(method: string, path: string): Match { | ||
| + let pathMatched = false; | ||
| + for (const r of this.routes) { | ||
| + const m = r.re.exec(path); | ||
| + if (!m) continue; | ||
| + pathMatched = true; | ||
| + if (r.method !== method && !(method === "HEAD" && r.method === "GET")) continue; | ||
| + const params: Params = {}; | ||
| + r.keys.forEach((k, i) => { | ||
| + params[k] = decodeURIComponent(m[i + 1]!); | ||
| + }); | ||
| + return { handler: r.handler, params }; | ||
| + } | ||
| + return pathMatched ? "method_not_allowed" : null; | ||
| + } | ||
| +} | ||
Addedtest/catalog.test.ts+56−0
| @@ -0,0 +1,56 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { setup } from "./helpers/app.ts"; | ||
| + | ||
| +test("lists active products by name", async () => { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks", { name: "Wool socks", price_cents: 1800 }); | ||
| + await s.addProduct("canvas-tote", { name: "Canvas tote", price_cents: 2400 }); | ||
| + await s.addProduct("old-mug", { name: "Old mug", active: false }); | ||
| + | ||
| + const res = await s.call("GET", "/products"); | ||
| + assert.equal(res.status, 200); | ||
| + assert.deepEqual(res.body.products.map((p: { id: string }) => p.id), ["canvas-tote", "wool-socks"]); | ||
| +}); | ||
| + | ||
| +test("an inactive product is not found by shoppers", async () => { | ||
| + const s = setup(); | ||
| + await s.addProduct("old-mug", { active: false }); | ||
| + assert.equal((await s.call("GET", "/products/old-mug")).status, 404); | ||
| +}); | ||
| + | ||
| +test("updating a product keeps its id and changes its fields", async () => { | ||
| + const s = setup(); | ||
| + await s.addProduct("wool-socks", { price_cents: 1800 }); | ||
| + await s.addProduct("wool-socks", { price_cents: 1500, stock: 40 }); | ||
| + const { body } = await s.call("GET", "/products/wool-socks"); | ||
| + assert.equal(body.product.price_cents, 1500); | ||
| + assert.equal(body.product.stock, 40); | ||
| +}); | ||
| + | ||
| +test("merchant routes need the admin token", async () => { | ||
| + const s = setup(); | ||
| + const res = await s.call("PUT", "/admin/products/wool-socks", { name: "Wool socks", price_cents: 1800 }, { authorization: "Bearer wrong" }); | ||
| + assert.equal(res.status, 401); | ||
| + assert.equal(res.body.error, "unauthorized"); | ||
| +}); | ||
| + | ||
| +test("rejects a price that is not whole cents", async () => { | ||
| + const s = setup(); | ||
| + const res = await s.call("PUT", "/admin/products/wool-socks", { name: "Wool socks", price_cents: 18.5 }, s.admin); | ||
| + assert.equal(res.status, 400); | ||
| + assert.equal(res.body.error, "bad_price"); | ||
| +}); | ||
| + | ||
| +test("rejects an id that is not a slug", async () => { | ||
| + const s = setup(); | ||
| + const res = await s.call("PUT", "/admin/products/Wool%20Socks", { name: "Wool socks", price_cents: 1800 }, s.admin); | ||
| + assert.equal(res.body.error, "bad_id"); | ||
| +}); | ||
| + | ||
| +test("unknown routes and methods", async () => { | ||
| + const s = setup(); | ||
| + assert.equal((await s.call("GET", "/nope")).status, 404); | ||
| + assert.equal((await s.call("DELETE", "/products")).status, 405); | ||
| + assert.equal((await s.call("GET", "/products/%E0%A4%A")).status, 400); | ||
| +}); | ||
Addedtest/helpers/app.ts+35−0
| @@ -0,0 +1,35 @@ | ||
| +// One storefront per test: a fresh database, a fixed clock and predictable ids. | ||
| + | ||
| +import { createApp } from "../../src/app.ts"; | ||
| +import type { Env } from "../../src/env.ts"; | ||
| +import { testDb } from "./d1.ts"; | ||
| + | ||
| +export const ADMIN_TOKEN = "test-admin-token"; | ||
| +export const NOW = Date.UTC(2026, 9, 1, 12, 0, 0); | ||
| + | ||
| +export function setup() { | ||
| + const env: Env = { DB: testDb(), ADMIN_TOKEN }; | ||
| + let n = 0; | ||
| + const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}` }); | ||
| + | ||
| + async function call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) { | ||
| + const init: RequestInit = { method, headers: { ...headers } }; | ||
| + if (body !== undefined) { | ||
| + init.body = JSON.stringify(body); | ||
| + (init.headers as Record<string, string>)["content-type"] = "application/json"; | ||
| + } | ||
| + const res = await app.fetch(new Request(`https://shop.test${path}`, init)); | ||
| + const text = await res.text(); | ||
| + return { status: res.status, headers: res.headers, body: text ? JSON.parse(text) : null }; | ||
| + } | ||
| + | ||
| + const admin = { authorization: `Bearer ${ADMIN_TOKEN}` }; | ||
| + | ||
| + async function addProduct(id: string, fields: Record<string, unknown> = {}) { | ||
| + const res = await call("PUT", `/admin/products/${id}`, { name: id, price_cents: 1200, stock: 5, ...fields }, admin); | ||
| + if (res.status !== 200) throw new Error(`adding ${id}: ${JSON.stringify(res.body)}`); | ||
| + return res.body.product; | ||
| + } | ||
| + | ||
| + return { env, app, call, admin, addProduct }; | ||
| +} | ||
Addedtest/helpers/d1.ts+74−0
| @@ -0,0 +1,74 @@ | ||
| +// A D1 stand-in over node:sqlite with every file in migrations/ applied, so tests run the | ||
| +// storefront's real SQL. It covers what the code calls: prepare, bind, first, all, run and batch. | ||
| +// Foreign keys are on, as they are in D1. | ||
| + | ||
| +import { readdirSync, readFileSync } from "node:fs"; | ||
| +import { dirname, join } from "node:path"; | ||
| +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; | ||
| +import { fileURLToPath } from "node:url"; | ||
| + | ||
| +const MIGRATIONS = join(dirname(fileURLToPath(import.meta.url)), "../../migrations"); | ||
| + | ||
| +class Statement { | ||
| + readonly db: DatabaseSync; | ||
| + readonly sql: string; | ||
| + readonly args: SQLInputValue[]; | ||
| + | ||
| + constructor(db: DatabaseSync, sql: string, args: SQLInputValue[] = []) { | ||
| + this.db = db; | ||
| + this.sql = sql; | ||
| + this.args = args; | ||
| + } | ||
| + | ||
| + bind(...args: unknown[]): Statement { | ||
| + return new Statement(this.db, this.sql, args as SQLInputValue[]); | ||
| + } | ||
| + | ||
| + async first<T>(column?: string): Promise<T | null> { | ||
| + const row = this.db.prepare(this.sql).get(...this.args) as Record<string, unknown> | undefined; | ||
| + if (!row) return null; | ||
| + return (column ? row[column] : { ...row }) as T; | ||
| + } | ||
| + | ||
| + async all<T>(): Promise<{ results: T[]; success: true; meta: Record<string, unknown> }> { | ||
| + const rows = this.db.prepare(this.sql).all(...this.args) as Record<string, unknown>[]; | ||
| + return { results: rows.map((r) => ({ ...r }) as T), success: true, meta: {} }; | ||
| + } | ||
| + | ||
| + async run(): Promise<{ results: []; success: true; meta: { changes: number; last_row_id: number } }> { | ||
| + const r = this.db.prepare(this.sql).run(...this.args); | ||
| + return { results: [], success: true, meta: { changes: Number(r.changes), last_row_id: Number(r.lastInsertRowid) } }; | ||
| + } | ||
| +} | ||
| + | ||
| +export class TestD1 { | ||
| + readonly sqlite = new DatabaseSync(":memory:"); | ||
| + | ||
| + constructor() { | ||
| + this.sqlite.exec("PRAGMA foreign_keys = ON"); | ||
| + const files = readdirSync(MIGRATIONS).filter((f) => f.endsWith(".sql")).sort(); | ||
| + for (const f of files) this.sqlite.exec(readFileSync(join(MIGRATIONS, f), "utf8")); | ||
| + } | ||
| + | ||
| + prepare(sql: string): Statement { | ||
| + return new Statement(this.sqlite, sql); | ||
| + } | ||
| + | ||
| + // Like D1, a batch is one transaction: a statement that fails rolls the others back. | ||
| + async batch(statements: Statement[]) { | ||
| + this.sqlite.exec("BEGIN"); | ||
| + try { | ||
| + const out = []; | ||
| + for (const s of statements) out.push(await s.run()); | ||
| + this.sqlite.exec("COMMIT"); | ||
| + return out; | ||
| + } catch (e) { | ||
| + this.sqlite.exec("ROLLBACK"); | ||
| + throw e; | ||
| + } | ||
| + } | ||
| +} | ||
| + | ||
| +export function testDb(): D1Database { | ||
| + return new TestD1() as unknown as D1Database; | ||
| +} | ||
Addedtsconfig.json+16−0
| @@ -0,0 +1,16 @@ | ||
| +{ | ||
| + "compilerOptions": { | ||
| + "target": "es2023", | ||
| + "module": "esnext", | ||
| + "moduleResolution": "bundler", | ||
| + "lib": ["es2023"], | ||
| + "types": ["@cloudflare/workers-types", "node"], | ||
| + "strict": true, | ||
| + "noEmit": true, | ||
| + "allowImportingTsExtensions": true, | ||
| + "verbatimModuleSyntax": true, | ||
| + "erasableSyntaxOnly": true, | ||
| + "skipLibCheck": true | ||
| + }, | ||
| + "include": ["src", "workers", "test"] | ||
| +} | ||
Addedwrangler.jsonc+17−0
| @@ -0,0 +1,17 @@ | ||
| +// storefront-api: the public HTTP API. Create the database once with | ||
| +// `npx wrangler d1 create storefront` and put its id in database_id. | ||
| +{ | ||
| + "$schema": "node_modules/wrangler/config-schema.json", | ||
| + "name": "storefront-api", | ||
| + "main": "src/index.ts", | ||
| + "compatibility_date": "2026-09-01", | ||
| + "observability": { "enabled": true }, | ||
| + "d1_databases": [ | ||
| + { | ||
| + "binding": "DB", | ||
| + "database_name": "storefront", | ||
| + "database_id": "", | ||
| + "migrations_dir": "migrations" | ||
| + } | ||
| + ] | ||
| +} | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.