diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f50b51b..feb0726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,22 @@ jobs: validate: runs-on: ubuntu-latest timeout-minutes: 20 + env: + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/hooky_test + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: hooky_test + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d hooky_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/README.md b/README.md index 84c2918..eefe33c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,12 @@ The product plan and implementation decisions live in [PRD issue #1](https://git ## Repository -This Bun workspace currently contains the Next.js web application in `apps/web`. Additional database, relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them. +This Bun workspace currently contains: + +- `apps/web`: the Next.js web application and API surface. +- `packages/database`: the Neon/PostgreSQL schema, migrations, and durable delivery state machine. + +Relay-core, shared, CLI, and test-support packages will be introduced with the vertical slices that need them. ## Development @@ -19,6 +24,24 @@ bun run dev The application runs at [http://localhost:3000](http://localhost:3000), with its health endpoint at [http://localhost:3000/api/health](http://localhost:3000/api/health). +## Database + +Application traffic must use Neon's pooled connection string through `DATABASE_URL`. The database package keeps captured webhook events immutable and uses short PostgreSQL statements with `FOR UPDATE SKIP LOCKED` for concurrent delivery claims. + +Generate a migration after changing the Drizzle schema: + +```bash +bun run db:generate +``` + +Apply committed migrations to the database in `DATABASE_URL`: + +```bash +bun run db:migrate +``` + +Database integration tests use `TEST_DATABASE_URL` when supplied. Otherwise, they start and remove an ephemeral local PostgreSQL cluster with `initdb` and `pg_ctl`. + ## Validation ```bash diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..80e15a1 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,2 @@ +# Use the pooled Neon connection string for application traffic. +DATABASE_URL=postgresql://user:password@endpoint-pooler.region.aws.neon.tech/database?sslmode=require diff --git a/bun.lock b/bun.lock index be99215..849ab2d 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,22 @@ "typescript": "^5.9.3", }, }, + "packages/database": { + "name": "@hooky/database", + "version": "0.0.0", + "dependencies": { + "drizzle-orm": "0.45.2", + "pg": "8.23.0", + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/pg": "8.21.0", + "drizzle-kit": "0.31.10", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "8.67.0", + }, + }, }, "trustedDependencies": [ "sharp", @@ -67,12 +83,70 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -91,6 +165,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@hooky/database": ["@hooky/database@workspace:packages/database"], + "@hooky/web": ["@hooky/web@workspace:apps/web"], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -169,6 +245,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + "@neondatabase/serverless": ["@neondatabase/serverless@1.1.0", "", {}, "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q=="], + "@next/env": ["@next/env@16.3.0", "", {}, "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw=="], @@ -227,6 +305,8 @@ "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "@types/pg": ["@types/pg@8.21.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], @@ -343,6 +423,8 @@ "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], @@ -391,6 +473,10 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.404", "", {}, "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g=="], @@ -415,6 +501,8 @@ "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -677,6 +765,22 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -689,6 +793,14 @@ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], @@ -727,7 +839,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -749,8 +861,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -787,6 +905,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + "turbo": ["turbo@2.10.9", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.9", "@turbo/darwin-arm64": "2.10.9", "@turbo/linux-64": "2.10.9", "@turbo/linux-arm64": "2.10.9", "@turbo/windows-64": "2.10.9", "@turbo/windows-arm64": "2.10.9" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Yl9+ukxH+UmPtKidpDkjn82tvPoEvFNb9UACd9vUomN1Ft0cwl3rx0P8yC1D93W9EOsWRMjllvIDG8y25sFOog=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -825,6 +945,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -835,6 +957,12 @@ "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -847,8 +975,6 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -857,18 +983,120 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "is-bun-module/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@next/eslint-plugin-next/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/package.json b/package.json index 1611b94..4ac0825 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ ], "scripts": { "build": "turbo run build", + "db:generate": "bun run --cwd packages/database generate", + "db:migrate": "bun run --cwd packages/database migrate", "dev": "turbo run dev --filter=@hooky/web", "lint": "turbo run lint", "prettier": "prettier --write .", diff --git a/packages/database/drizzle.config.ts b/packages/database/drizzle.config.ts new file mode 100644 index 0000000..51123bf --- /dev/null +++ b/packages/database/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/schema.ts", + out: "./migrations", + dialect: "postgresql", + dbCredentials: { + url: + process.env.DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:5432/hooky", + }, + strict: true, + verbose: true, +}); diff --git a/packages/database/eslint.config.mjs b/packages/database/eslint.config.mjs new file mode 100644 index 0000000..9192a5a --- /dev/null +++ b/packages/database/eslint.config.mjs @@ -0,0 +1,13 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/**", "migrations/**"], + }, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/consistent-type-imports": "error", + }, + }, +); diff --git a/packages/database/migrations/0000_colossal_cardiac.sql b/packages/database/migrations/0000_colossal_cardiac.sql new file mode 100644 index 0000000..989ad78 --- /dev/null +++ b/packages/database/migrations/0000_colossal_cardiac.sql @@ -0,0 +1,85 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto";--> statement-breakpoint +CREATE TYPE "public"."delivery_attempt_outcome" AS ENUM('delivered', 'failed', 'expired');--> statement-breakpoint +CREATE TYPE "public"."delivery_status" AS ENUM('pending', 'in_flight', 'delivered', 'dead');--> statement-breakpoint +CREATE TYPE "public"."hook_state" AS ENUM('active', 'disabled');--> statement-breakpoint +CREATE TABLE "accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "deliveries" ( + "id" uuid PRIMARY KEY NOT NULL, + "account_id" uuid NOT NULL, + "hook_id" uuid NOT NULL, + "event_id" uuid NOT NULL, + "status" "delivery_status" DEFAULT 'pending' NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "available_at" timestamp with time zone NOT NULL, + "leased_by" text, + "lease_token_hash" text, + "leased_until" timestamp with time zone, + "delivered_at" timestamp with time zone, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "deliveries_account_id_id_unique" UNIQUE("account_id","id"), + CONSTRAINT "deliveries_event_id_unique" UNIQUE("event_id") +); +--> statement-breakpoint +CREATE TABLE "delivery_attempts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "delivery_id" uuid NOT NULL, + "attempt_number" integer NOT NULL, + "listener_id" text NOT NULL, + "lease_token_hash" text NOT NULL, + "outcome" "delivery_attempt_outcome", + "error" text, + "started_at" timestamp with time zone NOT NULL, + "finished_at" timestamp with time zone, + CONSTRAINT "delivery_attempts_delivery_number_unique" UNIQUE("delivery_id","attempt_number") +); +--> statement-breakpoint +CREATE TABLE "hooks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "name" text NOT NULL, + "state" "hook_state" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hooks_account_id_id_unique" UNIQUE("account_id","id") +); +--> statement-breakpoint +CREATE TABLE "webhook_events" ( + "id" uuid PRIMARY KEY NOT NULL, + "account_id" uuid NOT NULL, + "hook_id" uuid NOT NULL, + "request_method" text NOT NULL, + "request_path" text NOT NULL, + "query" jsonb DEFAULT '{}'::jsonb NOT NULL, + "headers" jsonb DEFAULT '{}'::jsonb NOT NULL, + "body" bytea NOT NULL, + "body_sha256" text NOT NULL, + "received_at" timestamp with time zone NOT NULL, + CONSTRAINT "webhook_events_account_id_id_unique" UNIQUE("account_id","id") +); +--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_account_hook_fk" FOREIGN KEY ("account_id","hook_id") REFERENCES "public"."hooks"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_account_event_fk" FOREIGN KEY ("account_id","event_id") REFERENCES "public"."webhook_events"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "delivery_attempts" ADD CONSTRAINT "delivery_attempts_account_delivery_fk" FOREIGN KEY ("account_id","delivery_id") REFERENCES "public"."deliveries"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hooks" ADD CONSTRAINT "hooks_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_events" ADD CONSTRAINT "webhook_events_account_hook_fk" FOREIGN KEY ("account_id","hook_id") REFERENCES "public"."hooks"("account_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "deliveries_claim_index" ON "deliveries" USING btree ("account_id","hook_id","status","available_at","leased_until");--> statement-breakpoint +CREATE INDEX "delivery_attempts_account_delivery_index" ON "delivery_attempts" USING btree ("account_id","delivery_id");--> statement-breakpoint +CREATE UNIQUE INDEX "hooks_account_id_name_unique" ON "hooks" USING btree ("account_id","name");--> statement-breakpoint +CREATE INDEX "webhook_events_hook_received_at_index" ON "webhook_events" USING btree ("account_id","hook_id","received_at");--> statement-breakpoint +CREATE FUNCTION reject_webhook_event_update() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'webhook events are immutable'; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER webhook_events_immutable + BEFORE UPDATE ON webhook_events + FOR EACH ROW + EXECUTE FUNCTION reject_webhook_event_update(); diff --git a/packages/database/migrations/meta/0000_snapshot.json b/packages/database/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..4b7b8e0 --- /dev/null +++ b/packages/database/migrations/meta/0000_snapshot.json @@ -0,0 +1,564 @@ +{ + "id": "46766050-cbca-4a54-8644-cbfedaa5f60f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hook_id": { + "name": "hook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "leased_by": { + "name": "leased_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token_hash": { + "name": "lease_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leased_until": { + "name": "leased_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_claim_index": { + "name": "deliveries_claim_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "leased_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_account_hook_fk": { + "name": "deliveries_account_hook_fk", + "tableFrom": "deliveries", + "tableTo": "hooks", + "columnsFrom": ["account_id", "hook_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_account_event_fk": { + "name": "deliveries_account_event_fk", + "tableFrom": "deliveries", + "tableTo": "webhook_events", + "columnsFrom": ["account_id", "event_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deliveries_account_id_id_unique": { + "name": "deliveries_account_id_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id", "id"] + }, + "deliveries_event_id_unique": { + "name": "deliveries_event_id_unique", + "nullsNotDistinct": false, + "columns": ["event_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_attempts": { + "name": "delivery_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listener_id": { + "name": "listener_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_token_hash": { + "name": "lease_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "delivery_attempt_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "delivery_attempts_account_delivery_index": { + "name": "delivery_attempts_account_delivery_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_attempts_account_delivery_fk": { + "name": "delivery_attempts_account_delivery_fk", + "tableFrom": "delivery_attempts", + "tableTo": "deliveries", + "columnsFrom": ["account_id", "delivery_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_attempts_delivery_number_unique": { + "name": "delivery_attempts_delivery_number_unique", + "nullsNotDistinct": false, + "columns": ["delivery_id", "attempt_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hooks": { + "name": "hooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "hook_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hooks_account_id_name_unique": { + "name": "hooks_account_id_name_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hooks_account_id_accounts_id_fk": { + "name": "hooks_account_id_accounts_id_fk", + "tableFrom": "hooks", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hooks_account_id_id_unique": { + "name": "hooks_account_id_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id", "id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hook_id": { + "name": "hook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_path": { + "name": "request_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "body": { + "name": "body", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "body_sha256": { + "name": "body_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "webhook_events_hook_received_at_index": { + "name": "webhook_events_hook_received_at_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_account_hook_fk": { + "name": "webhook_events_account_hook_fk", + "tableFrom": "webhook_events", + "tableTo": "hooks", + "columnsFrom": ["account_id", "hook_id"], + "columnsTo": ["account_id", "id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_events_account_id_id_unique": { + "name": "webhook_events_account_id_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id", "id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.delivery_attempt_outcome": { + "name": "delivery_attempt_outcome", + "schema": "public", + "values": ["delivered", "failed", "expired"] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": ["pending", "in_flight", "delivered", "dead"] + }, + "public.hook_state": { + "name": "hook_state", + "schema": "public", + "values": ["active", "disabled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json new file mode 100644 index 0000000..3df057a --- /dev/null +++ b/packages/database/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1786490390015, + "tag": "0000_colossal_cardiac", + "breakpoints": true + } + ] +} diff --git a/packages/database/package.json b/packages/database/package.json new file mode 100644 index 0000000..b1cf2d8 --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,29 @@ +{ + "name": "@hooky/database", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema.ts" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "generate": "drizzle-kit generate", + "lint": "eslint .", + "migrate": "drizzle-kit migrate", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "drizzle-orm": "0.45.2", + "pg": "8.23.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/pg": "8.21.0", + "drizzle-kit": "0.31.10", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "8.67.0" + } +} diff --git a/packages/database/src/database.ts b/packages/database/src/database.ts new file mode 100644 index 0000000..d0d30f6 --- /dev/null +++ b/packages/database/src/database.ts @@ -0,0 +1,14 @@ +import { Pool } from "pg"; + +export function createDatabasePool({ + connectionString, + maxConnections = 5, +}: { + connectionString: string; + maxConnections?: number; +}) { + return new Pool({ + connectionString, + max: maxConnections, + }); +} diff --git a/packages/database/src/delivery-store.test.ts b/packages/database/src/delivery-store.test.ts new file mode 100644 index 0000000..04b30be --- /dev/null +++ b/packages/database/src/delivery-store.test.ts @@ -0,0 +1,371 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { DeliveryStore, HookUnavailableError } from "./delivery-store"; +import { createTestDatabase } from "./testing/test-database"; + +let database: Awaited>; +let store: DeliveryStore; + +beforeAll(async () => { + database = await createTestDatabase(); + store = new DeliveryStore(database.pool); +}); + +beforeEach(async () => { + await database.reset(); +}); + +afterAll(async () => { + await database.close(); +}); + +describe("delivery store", () => { + test("records and claims a webhook event for its account", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + const receivedAt = new Date("2026-08-11T20:00:00.000Z"); + + const recorded = await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/checkout", + query: { attempt: "1" }, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"order":"ord_123"}'), + receivedAt, + }); + const claimed = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 10, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + expect(recorded.eventId).toBe(claimed[0]?.eventId); + expect(recorded.deliveryId).toBe(claimed[0]?.deliveryId); + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ + attemptNumber: 1, + requestMethod: "POST", + requestPath: "/checkout", + query: { attempt: "1" }, + headers: { "content-type": "application/json" }, + receivedAt, + }); + expect(claimed[0]?.body).toEqual(Buffer.from('{"order":"ord_123"}')); + expect(claimed[0]?.leaseToken).toMatch(/^[a-f0-9]{64}$/); + }); + + test("never exposes a delivery across account boundaries", async () => { + const owner = await database.seedAccountAndHook(); + const otherAccount = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId: owner.accountId, + hookId: owner.hookId, + requestMethod: "POST", + requestPath: "/private", + query: {}, + headers: {}, + body: Buffer.from("secret"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const claimedByOtherAccount = await store.claimDeliveries({ + accountId: otherAccount.accountId, + hookId: owner.hookId, + listenerId: "wrong-account", + limit: 10, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + expect(claimedByOtherAccount).toEqual([]); + }); + + test("allows only one listener to claim an active lease", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/concurrent", + query: {}, + headers: {}, + body: Buffer.from("concurrent"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const claims = await Promise.all( + ["listener-one", "listener-two"].map((listenerId) => + store.claimDeliveries({ + accountId, + hookId, + listenerId, + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }), + ), + ); + + expect(claims.flat()).toHaveLength(1); + }); + + test("redelivers an event after its lease expires", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/recover", + query: {}, + headers: {}, + body: Buffer.from("recover"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const [firstClaim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + const beforeExpiry = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:30.000Z"), + }); + const [recoveredClaim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:32.000Z"), + }); + + expect(beforeExpiry).toEqual([]); + expect(recoveredClaim?.deliveryId).toBe(firstClaim?.deliveryId); + expect(recoveredClaim?.attemptNumber).toBe(2); + expect(recoveredClaim?.leaseToken).not.toBe(firstClaim?.leaseToken); + }); + + test("acknowledges only the current active lease", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/ack", + query: {}, + headers: {}, + body: Buffer.from("ack"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + const rejectedToken = await store.acknowledgeDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: "incorrect-token", + now: new Date("2026-08-11T20:00:02.000Z"), + }); + const acknowledged = await store.acknowledgeDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + now: new Date("2026-08-11T20:00:03.000Z"), + }); + const duplicateAcknowledgement = await store.acknowledgeDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + now: new Date("2026-08-11T20:00:04.000Z"), + }); + const redelivery = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-12T20:00:00.000Z"), + }); + + expect(rejectedToken).toBe(false); + expect(acknowledged).toBe(true); + expect(duplicateAcknowledgement).toBe(false); + expect(redelivery).toEqual([]); + }); + + test("releases a rejected delivery at its requested retry time", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/retry", + query: {}, + headers: {}, + body: Buffer.from("retry"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + const retryAt = new Date("2026-08-11T20:01:00.000Z"); + + const rejected = await store.rejectDelivery({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + error: "Local destination returned 503", + retryAt, + now: new Date("2026-08-11T20:00:02.000Z"), + }); + const beforeRetry = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:59.000Z"), + }); + const [retryClaim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-two", + limit: 1, + leaseDurationSeconds: 30, + now: retryAt, + }); + + expect(rejected).toBe(true); + expect(beforeRetry).toEqual([]); + expect(retryClaim?.deliveryId).toBe(claim?.deliveryId); + expect(retryClaim?.attemptNumber).toBe(2); + }); + + test("extends an active lease from the heartbeat time", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + + await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/slow", + query: {}, + headers: {}, + body: Buffer.from("slow"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + const [claim] = await store.claimDeliveries({ + accountId, + hookId, + listenerId: "listener-one", + limit: 1, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:01.000Z"), + }); + + const leasedUntil = await store.extendDeliveryLease({ + accountId, + deliveryId: claim!.deliveryId, + leaseToken: claim!.leaseToken, + leaseDurationSeconds: 30, + now: new Date("2026-08-11T20:00:20.000Z"), + }); + + expect(leasedUntil).toEqual(new Date("2026-08-11T20:00:50.000Z")); + }); + + test("refuses to record against another account's hook", async () => { + const owner = await database.seedAccountAndHook(); + const otherAccount = await database.seedAccountAndHook(); + + const recording = store.recordWebhookEvent({ + accountId: otherAccount.accountId, + hookId: owner.hookId, + requestMethod: "POST", + requestPath: "/forbidden", + query: {}, + headers: {}, + body: Buffer.from("forbidden"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + await expect(recording).rejects.toBeInstanceOf(HookUnavailableError); + }); + + test("keeps captured webhook events immutable", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + const recorded = await store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/original", + query: {}, + headers: {}, + body: Buffer.from("immutable"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + const mutation = database.pool.query( + "update webhook_events set request_path = $1 where id = $2", + ["/changed", recorded.eventId], + ); + + await expect(mutation).rejects.toThrow("webhook events are immutable"); + }); + + test("refuses to record against a disabled hook", async () => { + const { accountId, hookId } = await database.seedAccountAndHook(); + await database.pool.query( + "update hooks set state = 'disabled' where id = $1", + [hookId], + ); + + const recording = store.recordWebhookEvent({ + accountId, + hookId, + requestMethod: "POST", + requestPath: "/disabled", + query: {}, + headers: {}, + body: Buffer.from("disabled"), + receivedAt: new Date("2026-08-11T20:00:00.000Z"), + }); + + await expect(recording).rejects.toBeInstanceOf(HookUnavailableError); + }); +}); diff --git a/packages/database/src/delivery-store.ts b/packages/database/src/delivery-store.ts new file mode 100644 index 0000000..fbca112 --- /dev/null +++ b/packages/database/src/delivery-store.ts @@ -0,0 +1,396 @@ +import { createHash } from "node:crypto"; + +import type { Pool } from "pg"; + +export class HookUnavailableError extends Error { + constructor() { + super( + "The hook does not exist, is disabled, or belongs to another account", + ); + this.name = "HookUnavailableError"; + } +} + +export type ClaimedDelivery = { + deliveryId: string; + eventId: string; + attemptNumber: number; + leaseToken: string; + leasedUntil: Date; + requestMethod: string; + requestPath: string; + query: Record; + headers: Record; + body: Buffer; + receivedAt: Date; +}; + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function clampInteger(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, Math.trunc(value))); +} + +export class DeliveryStore { + constructor(private readonly pool: Pool) {} + + async recordWebhookEvent({ + accountId, + hookId, + requestMethod, + requestPath, + query, + headers, + body, + receivedAt, + }: { + accountId: string; + hookId: string; + requestMethod: string; + requestPath: string; + query: Record; + headers: Record; + body: Buffer; + receivedAt: Date; + }) { + const eventId = crypto.randomUUID(); + const deliveryId = crypto.randomUUID(); + const bodySha256 = createHash("sha256").update(body).digest("hex"); + const result = await this.pool.query<{ + event_id: string; + delivery_id: string; + }>( + ` + with owned_hook as ( + select id + from hooks + where id = $1 + and account_id = $2 + and state = 'active' + ), inserted_event as ( + insert into webhook_events ( + id, + account_id, + hook_id, + request_method, + request_path, + query, + headers, + body, + body_sha256, + received_at + ) + select $3, $2, owned_hook.id, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11 + from owned_hook + returning id + ), inserted_delivery as ( + insert into deliveries ( + id, + account_id, + hook_id, + event_id, + available_at + ) + select $4, $2, $1, inserted_event.id, $11 + from inserted_event + returning id + ) + select inserted_event.id as event_id, inserted_delivery.id as delivery_id + from inserted_event + cross join inserted_delivery + `, + [ + hookId, + accountId, + eventId, + deliveryId, + requestMethod, + requestPath, + JSON.stringify(query), + JSON.stringify(headers), + body, + bodySha256, + receivedAt, + ], + ); + + const recorded = result.rows[0]; + if (!recorded) { + throw new HookUnavailableError(); + } + + return { + eventId: recorded.event_id, + deliveryId: recorded.delivery_id, + }; + } + + async claimDeliveries({ + accountId, + hookId, + listenerId, + limit, + leaseDurationSeconds, + now, + }: { + accountId: string; + hookId: string; + listenerId: string; + limit: number; + leaseDurationSeconds: number; + now: Date; + }): Promise { + const claimLimit = clampInteger(limit, 1, 10); + const leaseSeconds = clampInteger(leaseDurationSeconds, 1, 3600); + const result = await this.pool.query<{ + delivery_id: string; + event_id: string; + attempt_number: number; + lease_token: string; + leased_until: Date; + request_method: string; + request_path: string; + query: Record; + headers: Record; + body: Buffer; + received_at: Date; + }>( + ` + with candidates as materialized ( + select deliveries.id + from deliveries + inner join hooks + on hooks.id = deliveries.hook_id + and hooks.account_id = deliveries.account_id + where deliveries.account_id = $1 + and deliveries.hook_id = $2 + and hooks.state = 'active' + and ( + (deliveries.status = 'pending' and deliveries.available_at <= $3) + or ( + deliveries.status = 'in_flight' + and deliveries.leased_until <= $3 + ) + ) + order by deliveries.available_at, deliveries.created_at, deliveries.id + for update of deliveries skip locked + limit $4 + ), expired_attempts as ( + update delivery_attempts + set outcome = 'expired', finished_at = $3 + from deliveries + where delivery_attempts.delivery_id = deliveries.id + and deliveries.id in (select id from candidates) + and deliveries.status = 'in_flight' + and deliveries.leased_until <= $3 + and delivery_attempts.outcome is null + returning delivery_attempts.id + ), lease_values as ( + select + candidates.id, + replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', '') as lease_token + from candidates + ), claimed as ( + update deliveries + set + status = 'in_flight', + attempt_count = deliveries.attempt_count + 1, + leased_by = $5, + lease_token_hash = encode(digest(lease_values.lease_token, 'sha256'), 'hex'), + leased_until = $3::timestamptz + make_interval(secs => $6::double precision), + updated_at = $3 + from lease_values + where deliveries.id = lease_values.id + returning + deliveries.id as delivery_id, + deliveries.account_id, + deliveries.event_id, + deliveries.attempt_count as attempt_number, + deliveries.lease_token_hash, + deliveries.leased_until, + lease_values.lease_token + ), inserted_attempts as ( + insert into delivery_attempts ( + account_id, + delivery_id, + attempt_number, + listener_id, + lease_token_hash, + started_at + ) + select + claimed.account_id, + claimed.delivery_id, + claimed.attempt_number, + $5, + claimed.lease_token_hash, + $3 + from claimed + returning delivery_id + ) + select + claimed.delivery_id, + claimed.event_id, + claimed.attempt_number, + claimed.lease_token, + claimed.leased_until, + webhook_events.request_method, + webhook_events.request_path, + webhook_events.query, + webhook_events.headers, + webhook_events.body, + webhook_events.received_at + from claimed + inner join webhook_events on webhook_events.id = claimed.event_id + order by webhook_events.received_at, claimed.delivery_id + `, + [accountId, hookId, now, claimLimit, listenerId, leaseSeconds], + ); + + return result.rows.map((row) => ({ + deliveryId: row.delivery_id, + eventId: row.event_id, + attemptNumber: row.attempt_number, + leaseToken: row.lease_token, + leasedUntil: row.leased_until, + requestMethod: row.request_method, + requestPath: row.request_path, + query: row.query, + headers: row.headers, + body: row.body, + receivedAt: row.received_at, + })); + } + + async acknowledgeDelivery({ + accountId, + deliveryId, + leaseToken, + now, + }: { + accountId: string; + deliveryId: string; + leaseToken: string; + now: Date; + }) { + const result = await this.pool.query<{ accepted: boolean }>( + ` + with accepted_delivery as ( + update deliveries + set + status = 'delivered', + delivered_at = $4, + leased_by = null, + lease_token_hash = null, + leased_until = null, + last_error = null, + updated_at = $4 + where account_id = $1 + and id = $2 + and status = 'in_flight' + and lease_token_hash = $3 + and leased_until > $4 + returning id, attempt_count + ), completed_attempt as ( + update delivery_attempts + set outcome = 'delivered', finished_at = $4 + from accepted_delivery + where delivery_attempts.delivery_id = accepted_delivery.id + and delivery_attempts.attempt_number = accepted_delivery.attempt_count + returning delivery_attempts.id + ) + select exists(select 1 from accepted_delivery) as accepted + `, + [accountId, deliveryId, hashToken(leaseToken), now], + ); + + return result.rows[0]?.accepted ?? false; + } + + async rejectDelivery({ + accountId, + deliveryId, + leaseToken, + error, + retryAt, + now, + }: { + accountId: string; + deliveryId: string; + leaseToken: string; + error: string; + retryAt: Date; + now: Date; + }) { + const result = await this.pool.query<{ accepted: boolean }>( + ` + with rejected_delivery as ( + update deliveries + set + status = 'pending', + available_at = $5, + leased_by = null, + lease_token_hash = null, + leased_until = null, + last_error = $4, + updated_at = $6 + where account_id = $1 + and id = $2 + and status = 'in_flight' + and lease_token_hash = $3 + and leased_until > $6 + returning id, attempt_count + ), failed_attempt as ( + update delivery_attempts + set outcome = 'failed', error = $4, finished_at = $6 + from rejected_delivery + where delivery_attempts.delivery_id = rejected_delivery.id + and delivery_attempts.attempt_number = rejected_delivery.attempt_count + returning delivery_attempts.id + ) + select exists(select 1 from rejected_delivery) as accepted + `, + [accountId, deliveryId, hashToken(leaseToken), error, retryAt, now], + ); + + return result.rows[0]?.accepted ?? false; + } + + async extendDeliveryLease({ + accountId, + deliveryId, + leaseToken, + leaseDurationSeconds, + now, + }: { + accountId: string; + deliveryId: string; + leaseToken: string; + leaseDurationSeconds: number; + now: Date; + }) { + const leaseSeconds = clampInteger(leaseDurationSeconds, 1, 3600); + const result = await this.pool.query<{ leased_until: Date }>( + ` + update deliveries + set + leased_until = greatest( + leased_until, + $4::timestamptz + make_interval(secs => $5::double precision) + ), + updated_at = $4 + where account_id = $1 + and id = $2 + and status = 'in_flight' + and lease_token_hash = $3 + and leased_until > $4 + returning leased_until + `, + [accountId, deliveryId, hashToken(leaseToken), now, leaseSeconds], + ); + + return result.rows[0]?.leased_until; + } +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts new file mode 100644 index 0000000..c87cd8a --- /dev/null +++ b/packages/database/src/index.ts @@ -0,0 +1,7 @@ +export { createDatabasePool } from "./database"; +export { + DeliveryStore, + HookUnavailableError, + type ClaimedDelivery, +} from "./delivery-store"; +export * as schema from "./schema"; diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts new file mode 100644 index 0000000..ea59f62 --- /dev/null +++ b/packages/database/src/schema.ts @@ -0,0 +1,174 @@ +import { + customType, + foreignKey, + index, + integer, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + unique, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType() { + return "bytea"; + }, +}); + +export const hookState = pgEnum("hook_state", ["active", "disabled"]); +export const deliveryStatus = pgEnum("delivery_status", [ + "pending", + "in_flight", + "delivered", + "dead", +]); +export const deliveryAttemptOutcome = pgEnum("delivery_attempt_outcome", [ + "delivered", + "failed", + "expired", +]); + +export const accounts = pgTable("accounts", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +export const hooks = pgTable( + "hooks", + { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + name: text("name").notNull(), + state: hookState("state").default("active").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + unique("hooks_account_id_id_unique").on(table.accountId, table.id), + uniqueIndex("hooks_account_id_name_unique").on(table.accountId, table.name), + ], +); + +export const webhookEvents = pgTable( + "webhook_events", + { + id: uuid("id").primaryKey(), + accountId: uuid("account_id").notNull(), + hookId: uuid("hook_id").notNull(), + requestMethod: text("request_method").notNull(), + requestPath: text("request_path").notNull(), + query: jsonb("query") + .$type>() + .default({}) + .notNull(), + headers: jsonb("headers") + .$type>() + .default({}) + .notNull(), + body: bytea("body").notNull(), + bodySha256: text("body_sha256").notNull(), + receivedAt: timestamp("received_at", { withTimezone: true }).notNull(), + }, + (table) => [ + unique("webhook_events_account_id_id_unique").on(table.accountId, table.id), + foreignKey({ + columns: [table.accountId, table.hookId], + foreignColumns: [hooks.accountId, hooks.id], + name: "webhook_events_account_hook_fk", + }).onDelete("cascade"), + index("webhook_events_hook_received_at_index").on( + table.accountId, + table.hookId, + table.receivedAt, + ), + ], +); + +export const deliveries = pgTable( + "deliveries", + { + id: uuid("id").primaryKey(), + accountId: uuid("account_id").notNull(), + hookId: uuid("hook_id").notNull(), + eventId: uuid("event_id").notNull(), + status: deliveryStatus("status").default("pending").notNull(), + attemptCount: integer("attempt_count").default(0).notNull(), + availableAt: timestamp("available_at", { withTimezone: true }).notNull(), + leasedBy: text("leased_by"), + leaseTokenHash: text("lease_token_hash"), + leasedUntil: timestamp("leased_until", { withTimezone: true }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + unique("deliveries_account_id_id_unique").on(table.accountId, table.id), + unique("deliveries_event_id_unique").on(table.eventId), + foreignKey({ + columns: [table.accountId, table.hookId], + foreignColumns: [hooks.accountId, hooks.id], + name: "deliveries_account_hook_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [table.accountId, table.eventId], + foreignColumns: [webhookEvents.accountId, webhookEvents.id], + name: "deliveries_account_event_fk", + }).onDelete("cascade"), + index("deliveries_claim_index").on( + table.accountId, + table.hookId, + table.status, + table.availableAt, + table.leasedUntil, + ), + ], +); + +export const deliveryAttempts = pgTable( + "delivery_attempts", + { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id").notNull(), + deliveryId: uuid("delivery_id").notNull(), + attemptNumber: integer("attempt_number").notNull(), + listenerId: text("listener_id").notNull(), + leaseTokenHash: text("lease_token_hash").notNull(), + outcome: deliveryAttemptOutcome("outcome"), + error: text("error"), + startedAt: timestamp("started_at", { withTimezone: true }).notNull(), + finishedAt: timestamp("finished_at", { withTimezone: true }), + }, + (table) => [ + unique("delivery_attempts_delivery_number_unique").on( + table.deliveryId, + table.attemptNumber, + ), + foreignKey({ + columns: [table.accountId, table.deliveryId], + foreignColumns: [deliveries.accountId, deliveries.id], + name: "delivery_attempts_account_delivery_fk", + }).onDelete("cascade"), + index("delivery_attempts_account_delivery_index").on( + table.accountId, + table.deliveryId, + ), + ], +); diff --git a/packages/database/src/testing/test-database.ts b/packages/database/src/testing/test-database.ts new file mode 100644 index 0000000..da6d53a --- /dev/null +++ b/packages/database/src/testing/test-database.ts @@ -0,0 +1,137 @@ +import { execFileSync, spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; + +async function reservePort() { + const server = createServer(); + + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Unable to reserve a PostgreSQL test port"); + } + + await new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())); + }); + + return address.port; +} + +async function waitForPostgres(pool: Pool) { + let lastError: unknown; + + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + await pool.query("select 1"); + return; + } catch (error) { + lastError = error; + await Bun.sleep(50); + } + } + + throw new Error("Ephemeral PostgreSQL did not become ready", { + cause: lastError, + }); +} + +async function startEphemeralPostgres() { + const dataDirectory = await mkdtemp(join(tmpdir(), "hooky-postgres-")); + const port = await reservePort(); + + execFileSync( + "initdb", + [ + "-D", + dataDirectory, + "-A", + "trust", + "-U", + "postgres", + "--no-locale", + "-E", + "UTF8", + ], + { stdio: "ignore" }, + ); + + const process = spawn( + "postgres", + ["-D", dataDirectory, "-p", String(port), "-k", dataDirectory], + { stdio: "ignore" }, + ); + const connectionString = `postgresql://postgres@127.0.0.1:${port}/postgres`; + const pool = new Pool({ connectionString }); + + try { + await waitForPostgres(pool); + } catch (error) { + process.kill("SIGTERM"); + await rm(dataDirectory, { recursive: true, force: true }); + throw error; + } + + return { + connectionString, + async stop() { + await pool.end(); + execFileSync("pg_ctl", ["-D", dataDirectory, "stop", "-m", "fast"], { + stdio: "ignore", + }); + await rm(dataDirectory, { recursive: true, force: true }); + }, + }; +} + +export async function createTestDatabase() { + const configuredConnectionString = process.env.TEST_DATABASE_URL; + const ephemeral = configuredConnectionString + ? undefined + : await startEphemeralPostgres(); + const pool = new Pool({ + connectionString: + configuredConnectionString ?? ephemeral?.connectionString ?? "", + }); + + await migrate(drizzle(pool), { + migrationsFolder: resolve(import.meta.dir, "../../migrations"), + }); + + return { + pool, + async reset() { + await pool.query("truncate table accounts restart identity cascade"); + }, + async seedAccountAndHook() { + const accountId = crypto.randomUUID(); + const hookId = crypto.randomUUID(); + + await pool.query(`insert into accounts (id, name) values ($1, $2)`, [ + accountId, + "Test account", + ]); + await pool.query( + `insert into hooks (id, account_id, name) values ($1, $2, $3)`, + [hookId, accountId, "stripe-dev"], + ); + + return { accountId, hookId }; + }, + async close() { + await pool.end(); + await ephemeral?.stop(); + }, + }; +} diff --git a/packages/database/tsconfig.build.json b/packages/database/tsconfig.build.json new file mode 100644 index 0000000..338e994 --- /dev/null +++ b/packages/database/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/testing/**"] +} diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 0000000..b7d0e0d --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["bun"] + }, + "include": ["drizzle.config.ts", "src/**/*.ts"] +}