From 27ecd5a839f9dcd4c6280ee371ea0004dee50c5e Mon Sep 17 00:00:00 2001 From: Promise Date: Fri, 26 Jun 2026 23:00:13 +0100 Subject: [PATCH] feat(indexer): add database foundation (config, connection, health, migrations) Adds the PostgreSQL-backed persistence foundation for the indexer in the `@fundable-indexer/common` package, covering issues #20-#23: #20 Database tooling - Add exact-pinned deps: drizzle-orm 0.45.2, postgres 3.4.9, zod 3.25.67, drizzle-kit 0.31.10 (dev); update bun.lock - Add drizzle.config.ts (postgresql, schema entrypoint, ./migrations out) - Add initial migrations/ directory with Drizzle journal - Add db:generate / db:migrate scripts (package + root indexer:db:* ) - Document the migration workflow in indexer/README.md #21 Config loader + validation - Add zod-based loadConfig in common/src/config; validates required INDEXER_DATABASE_URL, parses numeric INDEXER_PORT / POLL_INTERVAL_MS / START_LEDGER, aggregates problems into a clear ConfigValidationError #22 Connection factory - Add createDbClient in common/src/db wrapping postgres.js + Drizzle, reading from validated config, with an injectable sql factory for tests #23 Health check - Add checkDbHealth running `select 1`, returning typed healthy/unhealthy results without throwing Also scope root ESLint away from the indexer workspace (it is linted by Biome) and ignore generated migration metadata in Biome. Co-Authored-By: Claude Opus 4.8 --- bun.lock | 142 ++++++++++++++++--- eslint.config.mjs | 2 + indexer/README.md | 33 ++++- indexer/biome.jsonc | 2 +- indexer/common/drizzle.config.ts | 23 +++ indexer/common/migrations/README.md | 5 + indexer/common/migrations/meta/_journal.json | 1 + indexer/common/package.json | 10 ++ indexer/common/src/config/env.test.ts | 70 +++++++++ indexer/common/src/config/env.ts | 96 +++++++++++++ indexer/common/src/config/index.ts | 1 + indexer/common/src/db/client.test.ts | 39 +++++ indexer/common/src/db/client.ts | 52 +++++++ indexer/common/src/db/health.test.ts | 44 ++++++ indexer/common/src/db/health.ts | 32 +++++ indexer/common/src/db/index.ts | 7 + indexer/common/src/db/schema.ts | 9 ++ indexer/common/src/index.ts | 10 ++ package.json | 2 + 19 files changed, 555 insertions(+), 25 deletions(-) create mode 100644 indexer/common/drizzle.config.ts create mode 100644 indexer/common/migrations/README.md create mode 100644 indexer/common/migrations/meta/_journal.json create mode 100644 indexer/common/src/config/env.test.ts create mode 100644 indexer/common/src/config/env.ts create mode 100644 indexer/common/src/config/index.ts create mode 100644 indexer/common/src/db/client.test.ts create mode 100644 indexer/common/src/db/client.ts create mode 100644 indexer/common/src/db/health.test.ts create mode 100644 indexer/common/src/db/health.ts create mode 100644 indexer/common/src/db/index.ts create mode 100644 indexer/common/src/db/schema.ts diff --git a/bun.lock b/bun.lock index 6d3632a..6ec7825 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,14 @@ "indexer/common": { "name": "@fundable-indexer/common", "version": "0.1.0", + "dependencies": { + "drizzle-orm": "0.45.2", + "postgres": "3.4.9", + "zod": "3.25.67", + }, + "devDependencies": { + "drizzle-kit": "0.31.10", + }, }, "indexer/distributions": { "name": "@fundable-indexer/distributions", @@ -90,6 +98,12 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@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.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="], @@ -414,6 +428,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "c8": ["c8@11.0.0", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "monocart-coverage-reports": "^2" }, "optionalPeers": ["monocart-coverage-reports"], "bin": "bin/c8.js" }, "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg=="], @@ -480,6 +496,10 @@ "dotenv": ["dotenv@16.6.0", "", {}, "sha512-Omf1L8paOy2VJhILjyhrhqwLIdstqm1BvcDPKg4NGAlkwEu9ODyrFbvk8UymUOMCT+HXo31jg1lArIrVAAhuGA=="], + "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=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -798,6 +818,8 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], @@ -874,8 +896,12 @@ "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + "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=="], "sql-highlight": ["sql-highlight@6.1.0", "", {}, "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA=="], @@ -1000,6 +1026,8 @@ "zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="], + "@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=="], @@ -1028,10 +1056,10 @@ "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "color/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + "drizzle-kit/tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1048,25 +1076,55 @@ "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "test-exclude/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "test-exclude/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], - "test-exclude/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], - "test-exclude/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], - "vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "@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=="], - "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@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=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -1078,10 +1136,10 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "color/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "drizzle-kit/tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1090,10 +1148,6 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "test-exclude/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], @@ -1148,11 +1202,57 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "drizzle-kit/tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "drizzle-kit/tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "drizzle-kit/tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "drizzle-kit/tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "drizzle-kit/tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "drizzle-kit/tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "drizzle-kit/tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "drizzle-kit/tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "drizzle-kit/tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "drizzle-kit/tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "drizzle-kit/tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "drizzle-kit/tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "drizzle-kit/tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "drizzle-kit/tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "drizzle-kit/tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "test-exclude/glob/path-scurry/lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], diff --git a/eslint.config.mjs b/eslint.config.mjs index 755fe95..288bf19 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,8 @@ export default [ 'dist/**', 'coverage/**', 'src/migrations/**', + // The indexer is a separate workspace linted by Biome (bun run indexer:lint). + 'indexer/**', ], }, { files: ['**/*.{js,mjs,cjs,ts}'] }, diff --git a/indexer/README.md b/indexer/README.md index d6a5415..d160616 100644 --- a/indexer/README.md +++ b/indexer/README.md @@ -41,8 +41,35 @@ just lint Indexer environment variables are documented in the repository root `.env.example` under "Soroban indexer". +The `common` package exposes a validated config loader (`loadConfig`) that reads +these variables, parses numeric values (`INDEXER_PORT`, `POLL_INTERVAL_MS`, +`START_LEDGER`), and fails fast with a clear error when required values are +missing or malformed. + +## Database & Migrations + +Persistence uses PostgreSQL with [Drizzle ORM](https://orm.drizzle.team) and +Drizzle Kit for migrations. The schema entrypoint and connection factory live in +`common/src/db/`, and migration SQL is generated into `common/migrations/`. + +```bash +# Generate migration SQL from common/src/db/schema.ts +bun run indexer:db:generate + +# Apply pending migrations to INDEXER_DATABASE_URL +bun run indexer:db:migrate +``` + +Both commands read `INDEXER_DATABASE_URL` from the environment (see +`.env.example`). Domain tables (cursors, indexed events, streams, distributions) +are added by later scoped issues; until then `db:generate` reports no changes. + +From within `common`, the same steps are available as `bun run db:generate` and +`bun run db:migrate`. + ## Status -This workspace is currently a scaffold. The poller, database repositories, -cursor persistence, event handlers, and GraphQL API are planned but not yet -implemented. +This workspace provides the database tooling foundation — validated config, a +PostgreSQL connection factory, a health check, and Drizzle migration tooling. +The poller, domain tables, cursor persistence, event handlers, and GraphQL API +are planned but not yet implemented. diff --git a/indexer/biome.jsonc b/indexer/biome.jsonc index 04e3336..e55c666 100644 --- a/indexer/biome.jsonc +++ b/indexer/biome.jsonc @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "files": { - "ignore": ["**/.turbo", "**/coverage", "**/dist", "**/node_modules"] + "ignore": ["**/.turbo", "**/coverage", "**/dist", "**/migrations", "**/node_modules"] }, "formatter": { "enabled": true, diff --git a/indexer/common/drizzle.config.ts b/indexer/common/drizzle.config.ts new file mode 100644 index 0000000..0b7fb34 --- /dev/null +++ b/indexer/common/drizzle.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "drizzle-kit"; + +/** + * Drizzle Kit configuration for the indexer. + * + * Migration SQL is generated into ./migrations from the schema entrypoint in + * ./src/db/schema.ts. Domain tables are added by later scoped issues; this + * config only establishes the tooling and on-disk layout. + * + * The database URL is read directly from the environment here (rather than the + * validated config loader) because Drizzle Kit runs as a standalone CLI outside + * the application runtime. + */ +export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/schema.ts", + out: "./migrations", + dbCredentials: { + url: process.env.INDEXER_DATABASE_URL ?? "", + }, + strict: true, + verbose: true, +}); diff --git a/indexer/common/migrations/README.md b/indexer/common/migrations/README.md new file mode 100644 index 0000000..64a1fb2 --- /dev/null +++ b/indexer/common/migrations/README.md @@ -0,0 +1,5 @@ +# Drizzle migrations + +Generated SQL migrations and the `meta/` journal live here. +Run `bun run db:generate` (from this package) to create migrations from +`src/db/schema.ts`, and `bun run db:migrate` to apply them. diff --git a/indexer/common/migrations/meta/_journal.json b/indexer/common/migrations/meta/_journal.json new file mode 100644 index 0000000..f04877e --- /dev/null +++ b/indexer/common/migrations/meta/_journal.json @@ -0,0 +1 @@ +{"version":"7","dialect":"postgresql","entries":[]} \ No newline at end of file diff --git a/indexer/common/package.json b/indexer/common/package.json index da8956f..39c784e 100644 --- a/indexer/common/package.json +++ b/indexer/common/package.json @@ -9,9 +9,19 @@ "scripts": { "build": "tsc -p tsconfig.json", "codegen": "bun --print \"'common: no codegen configured yet'\"", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", "dev": "bun --watch src/index.ts", "lint": "biome check .", "test": "vitest run src", "type-check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "drizzle-orm": "0.45.2", + "postgres": "3.4.9", + "zod": "3.25.67" + }, + "devDependencies": { + "drizzle-kit": "0.31.10" } } diff --git a/indexer/common/src/config/env.test.ts b/indexer/common/src/config/env.test.ts new file mode 100644 index 0000000..6ed0c56 --- /dev/null +++ b/indexer/common/src/config/env.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; + +import { ConfigValidationError, loadConfig } from "./env.js"; + +const validEnv = { + INDEXER_DATABASE_URL: "postgres://postgres:postgres@localhost:5432/fundable_indexer", + INDEXER_PORT: "4000", + POLL_INTERVAL_MS: "5000", + START_LEDGER: "1000", + INDEXER_LOG_LEVEL: "debug", +} satisfies NodeJS.ProcessEnv; + +describe("loadConfig", () => { + test("returns typed values for a valid environment", () => { + const config = loadConfig(validEnv); + + expect(config).toEqual({ + databaseUrl: "postgres://postgres:postgres@localhost:5432/fundable_indexer", + port: 4000, + pollIntervalMs: 5000, + startLedger: 1000, + logLevel: "debug", + }); + }); + + test("defaults the log level and treats blank START_LEDGER as unset", () => { + const config = loadConfig({ + INDEXER_DATABASE_URL: validEnv.INDEXER_DATABASE_URL, + INDEXER_PORT: "4000", + POLL_INTERVAL_MS: "5000", + START_LEDGER: "", + }); + + expect(config.logLevel).toBe("info"); + expect(config.startLedger).toBeUndefined(); + expect("startLedger" in config).toBe(false); + }); + + test("fails with a clear error when a required value is missing", () => { + const { INDEXER_DATABASE_URL: _omitted, ...withoutUrl } = validEnv; + + expect(() => loadConfig(withoutUrl)).toThrow(ConfigValidationError); + expect(() => loadConfig(withoutUrl)).toThrow(/INDEXER_DATABASE_URL is required/); + }); + + test("fails with a clear error for a non-numeric port", () => { + expect(() => loadConfig({ ...validEnv, INDEXER_PORT: "not-a-number" })).toThrow( + /INDEXER_PORT must be a positive integer/, + ); + }); + + test("fails with a clear error for an invalid database URL", () => { + expect(() => loadConfig({ ...validEnv, INDEXER_DATABASE_URL: "not-a-url" })).toThrow( + /INDEXER_DATABASE_URL must be a valid connection URL/, + ); + }); + + test("aggregates multiple problems into one error", () => { + try { + loadConfig({ INDEXER_PORT: "abc", POLL_INTERVAL_MS: "xyz" }); + throw new Error("expected loadConfig to throw"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigValidationError); + const issues = (error as ConfigValidationError).issues; + expect(issues).toContain("INDEXER_DATABASE_URL is required"); + expect(issues).toContain("INDEXER_PORT must be a positive integer"); + expect(issues).toContain("POLL_INTERVAL_MS must be a positive integer"); + } + }); +}); diff --git a/indexer/common/src/config/env.ts b/indexer/common/src/config/env.ts new file mode 100644 index 0000000..d53210a --- /dev/null +++ b/indexer/common/src/config/env.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +/** + * Runtime configuration for the indexer. + * + * Values originate from environment variables (see `.env.example` under + * "Soroban indexer") and are validated once at startup. Downstream code — + * database, poller, and the future API — should depend on this typed shape + * rather than reading `process.env` directly. + */ +export interface IndexerConfig { + /** Postgres connection string, e.g. `postgres://user:pass@host:5432/db`. */ + readonly databaseUrl: string; + /** HTTP port the indexer/API listens on. */ + readonly port: number; + /** Delay between ledger polls, in milliseconds. */ + readonly pollIntervalMs: number; + /** Optional ledger to start indexing from; omit to resume from the cursor. */ + readonly startLedger?: number; + /** Logging verbosity. */ + readonly logLevel: "error" | "warn" | "info" | "debug"; +} + +/** A positive integer parsed from an environment string. */ +const positiveIntFromString = z + .string() + .trim() + .min(1, "must not be empty") + .regex(/^\d+$/, "must be a positive integer") + .transform((value) => Number.parseInt(value, 10)) + .refine((value) => Number.isSafeInteger(value), "must be a safe integer"); + +const configSchema = z.object({ + INDEXER_DATABASE_URL: z + .string({ required_error: "is required" }) + .trim() + .min(1, "is required") + .url("must be a valid connection URL"), + INDEXER_PORT: positiveIntFromString, + POLL_INTERVAL_MS: positiveIntFromString, + START_LEDGER: positiveIntFromString.optional(), + INDEXER_LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).optional().default("info"), +}); + +/** + * Raised when one or more environment variables are missing or invalid. + * The message lists every problem so misconfiguration can be fixed in one pass. + */ +export class ConfigValidationError extends Error { + constructor(public readonly issues: string[]) { + super(`Invalid indexer configuration:\n${issues.map((i) => ` - ${i}`).join("\n")}`); + this.name = "ConfigValidationError"; + } +} + +/** + * Validate environment variables and return a typed {@link IndexerConfig}. + * + * Treats empty strings as absent so that a blank `START_LEDGER=` in an env file + * is interpreted as "unset" rather than an invalid number. + * + * @throws {ConfigValidationError} if required values are missing or malformed. + */ +export function loadConfig(env: NodeJS.ProcessEnv = process.env): IndexerConfig { + const normalized: Record = {}; + for (const key of [ + "INDEXER_DATABASE_URL", + "INDEXER_PORT", + "POLL_INTERVAL_MS", + "START_LEDGER", + "INDEXER_LOG_LEVEL", + ]) { + const value = env[key]; + normalized[key] = value === undefined || value.trim() === "" ? undefined : value; + } + + const result = configSchema.safeParse(normalized); + if (!result.success) { + const issues = result.error.issues.map((issue) => { + const key = issue.path.join(".") || "config"; + return `${key} ${issue.message}`; + }); + throw new ConfigValidationError(issues); + } + + const parsed = result.data; + const config: IndexerConfig = { + databaseUrl: parsed.INDEXER_DATABASE_URL, + port: parsed.INDEXER_PORT, + pollIntervalMs: parsed.POLL_INTERVAL_MS, + logLevel: parsed.INDEXER_LOG_LEVEL, + ...(parsed.START_LEDGER !== undefined ? { startLedger: parsed.START_LEDGER } : {}), + }; + + return config; +} diff --git a/indexer/common/src/config/index.ts b/indexer/common/src/config/index.ts new file mode 100644 index 0000000..1872f04 --- /dev/null +++ b/indexer/common/src/config/index.ts @@ -0,0 +1 @@ +export { ConfigValidationError, loadConfig, type IndexerConfig } from "./env.js"; diff --git a/indexer/common/src/db/client.test.ts b/indexer/common/src/db/client.test.ts new file mode 100644 index 0000000..3e3434e --- /dev/null +++ b/indexer/common/src/db/client.test.ts @@ -0,0 +1,39 @@ +import type { Sql } from "postgres"; +import { describe, expect, test, vi } from "vitest"; + +import { createDbClient } from "./client.js"; + +/** Minimal postgres.js stand-in sufficient for Drizzle construction + close. */ +function mockSql(): Sql { + const sql = vi.fn(() => Promise.resolve([])); + Object.assign(sql, { + end: vi.fn(() => Promise.resolve()), + options: { parsers: {}, serializers: {} }, + }); + return sql as unknown as Sql; +} + +describe("createDbClient", () => { + test("builds a client from the configured database URL", () => { + const sql = mockSql(); + const createSql = vi.fn(() => sql); + + const client = createDbClient({ databaseUrl: "postgres://localhost:5432/test" }, { createSql }); + + expect(createSql).toHaveBeenCalledWith("postgres://localhost:5432/test"); + expect(client.sql).toBe(sql); + expect(client.db).toBeDefined(); + }); + + test("close() ends the underlying connection pool", async () => { + const sql = mockSql(); + + const client = createDbClient( + { databaseUrl: "postgres://localhost:5432/test" }, + { createSql: () => sql }, + ); + await client.close(); + + expect((sql as unknown as { end: ReturnType }).end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/indexer/common/src/db/client.ts b/indexer/common/src/db/client.ts new file mode 100644 index 0000000..6446973 --- /dev/null +++ b/indexer/common/src/db/client.ts @@ -0,0 +1,52 @@ +import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js"; +import postgres, { type Sql } from "postgres"; + +import type { IndexerConfig } from "../config/index.js"; + +/** + * A ready-to-use database entry point shared across the indexer. + * + * `db` is the Drizzle query interface; `sql` is the underlying postgres.js + * client for raw queries and lifecycle control (e.g. health checks, shutdown). + */ +export interface DbClient { + readonly db: PostgresJsDatabase; + readonly sql: Sql; + /** Close the connection pool. Call during graceful shutdown. */ + close(): Promise; +} + +export interface CreateDbClientOptions { + /** Maximum number of pooled connections. Defaults to 10. */ + readonly maxConnections?: number; + /** + * Factory for the underlying postgres.js client. Defaults to the real + * `postgres` driver; override in tests to inject a mocked connection. + */ + readonly createSql?: (url: string) => Sql; +} + +/** + * Create a PostgreSQL connection factory from validated configuration. + * + * The connection pool is lazy: no socket is opened until the first query, so + * constructing a client is cheap and side-effect free. + */ +export function createDbClient( + config: Pick, + options: CreateDbClientOptions = {}, +): DbClient { + const createSql = + options.createSql ?? ((url: string) => postgres(url, { max: options.maxConnections ?? 10 })); + + const sql = createSql(config.databaseUrl); + const db = drizzle(sql); + + return { + db, + sql, + async close() { + await sql.end(); + }, + }; +} diff --git a/indexer/common/src/db/health.test.ts b/indexer/common/src/db/health.test.ts new file mode 100644 index 0000000..3ff2def --- /dev/null +++ b/indexer/common/src/db/health.test.ts @@ -0,0 +1,44 @@ +import type { Sql } from "postgres"; +import { describe, expect, test, vi } from "vitest"; + +import { checkDbHealth } from "./health.js"; + +/** Build a minimal tagged-template stand-in for the postgres.js `sql` client. */ +function mockSql(behavior: () => Promise): Sql { + return vi.fn(behavior) as unknown as Sql; +} + +describe("checkDbHealth", () => { + test("reports healthy when the probe query succeeds", async () => { + const sql = mockSql(() => Promise.resolve([{ "?column?": 1 }])); + + const result = await checkDbHealth(sql); + + expect(result.healthy).toBe(true); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + expect(sql).toHaveBeenCalledTimes(1); + }); + + test("reports unhealthy with the error message when the probe fails", async () => { + const sql = mockSql(() => Promise.reject(new Error("connection refused"))); + + const result = await checkDbHealth(sql); + + expect(result.healthy).toBe(false); + if (!result.healthy) { + expect(result.error).toBe("connection refused"); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + } + }); + + test("stringifies non-Error rejections", async () => { + const sql = mockSql(() => Promise.reject("boom")); + + const result = await checkDbHealth(sql); + + expect(result.healthy).toBe(false); + if (!result.healthy) { + expect(result.error).toBe("boom"); + } + }); +}); diff --git a/indexer/common/src/db/health.ts b/indexer/common/src/db/health.ts new file mode 100644 index 0000000..fd6d040 --- /dev/null +++ b/indexer/common/src/db/health.ts @@ -0,0 +1,32 @@ +import type { Sql } from "postgres"; + +/** + * Result of a database health probe. `healthy` is the single field callers + * should branch on; `latencyMs` and `error` aid diagnostics and monitoring. + */ +export type DbHealth = + | { readonly healthy: true; readonly latencyMs: number } + | { readonly healthy: false; readonly latencyMs: number; readonly error: string }; + +/** + * Verify database availability by running a minimal `select 1` query. + * + * Never throws: a failed connection or query is reported as an unhealthy + * result so callers (local dev, Docker healthchecks, the future API) get a + * consistent typed answer. + * + * @param sql The postgres.js client to probe (e.g. {@link DbClient.sql}). + */ +export async function checkDbHealth(sql: Sql): Promise { + const start = Date.now(); + try { + await sql`select 1`; + return { healthy: true, latencyMs: Date.now() - start }; + } catch (error) { + return { + healthy: false, + latencyMs: Date.now() - start, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/indexer/common/src/db/index.ts b/indexer/common/src/db/index.ts new file mode 100644 index 0000000..5c4ff66 --- /dev/null +++ b/indexer/common/src/db/index.ts @@ -0,0 +1,7 @@ +export { + createDbClient, + type CreateDbClientOptions, + type DbClient, +} from "./client.js"; +export { checkDbHealth, type DbHealth } from "./health.js"; +export * as schema from "./schema.js"; diff --git a/indexer/common/src/db/schema.ts b/indexer/common/src/db/schema.ts new file mode 100644 index 0000000..1916dbd --- /dev/null +++ b/indexer/common/src/db/schema.ts @@ -0,0 +1,9 @@ +/** + * Drizzle schema entrypoint for the indexer database. + * + * Domain tables — cursors, indexed events, streams, and distributions — are + * defined by later scoped issues. This file exists so that migration tooling + * (drizzle-kit) and the connection factory have a single, stable schema entry + * to read from. Re-export table definitions from here as they are introduced. + */ +export {}; diff --git a/indexer/common/src/index.ts b/indexer/common/src/index.ts index a7a318b..648c66d 100644 --- a/indexer/common/src/index.ts +++ b/indexer/common/src/index.ts @@ -2,3 +2,13 @@ export const commonPackage = { name: "@fundable-indexer/common", role: "shared-infrastructure", } as const; + +export { ConfigValidationError, loadConfig, type IndexerConfig } from "./config/index.js"; +export { + checkDbHealth, + createDbClient, + schema, + type CreateDbClientOptions, + type DbClient, + type DbHealth, +} from "./db/index.js"; diff --git a/package.json b/package.json index 6fa0fd9..6bb0a6a 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "lint": "eslint --fix", "indexer:build": "turbo run build --filter=@fundable-indexer/common --filter=@fundable-indexer/streams --filter=@fundable-indexer/distributions", "indexer:codegen": "turbo run codegen --filter=@fundable-indexer/common --filter=@fundable-indexer/streams --filter=@fundable-indexer/distributions", + "indexer:db:generate": "cd indexer/common && bun run db:generate", + "indexer:db:migrate": "cd indexer/common && bun run db:migrate", "indexer:dev": "turbo run dev --parallel --filter=@fundable-indexer/common --filter=@fundable-indexer/streams --filter=@fundable-indexer/distributions", "indexer:format": "cd indexer && biome format --write .", "indexer:lint": "cd indexer && biome check .",