diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e433f0..af123ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +- Add `@maple-dev/effect-orm/database`, opt-in (see `docs/database.md`): a `Database` over the + Effect `SqlClient` you already use. `run(query, params?)` compiles a query for the database's + dialect, runs it and decodes its rows; `sql\`...\`` writes the other statements with every + value bound (and `sql.identifier` for names); `query` decodes rows through an optional schema. + `transaction` runs Effect's own `withTransaction`, nesting as savepoints, and adds isolation + level, access mode and deferrable settings; typed `TransactionCommitFailed` / + `TransactionRollbackFailed` where Effect 4.0.0 dies; `retryContention` for SQLSTATE 40001 / + 40P01; `requireTransaction` to mark helpers that must be atomic, checked at compile time; and + a `TransactionClosed` defect for statements that outlive their transaction. ClickHouse + declares no transactions and fails with `TransactionUnsupported` before sending anything. +- Add `Dialect.transactions` (`DialectTransactions`, `IsolationLevel`, `TransactionSettings`): + what transactions a dialect supports. Optional; absent means none. +- Add `CompiledQuery.dialect`: the name of the dialect a query was compiled for, so an executor + can refuse one compiled for another database. `rawCompiledQuery` takes it as an option. +- Dev: PGlite 0.5. It takes the session time zone from the host, so tests pin it to UTC. + - Add schema-as-code and migrations for ClickHouse, all opt-in (see `docs/migrations.md`): - `./schema`: `defineTable` (a `Table` that also carries its DDL), `materializedView` (its body is a DSL query, type-checked against the target table), DDL rendering with replicated diff --git a/bun.lock b/bun.lock index ea83fbd..3b60ec2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 2, + "lockfileVersion": 1, "configVersion": 1, "workspaces": { "": { @@ -9,8 +9,9 @@ "@effect/language-service": "^0.87.3", "@effect/platform-bun": "4.0.0", "@effect/sql-clickhouse": "4.0.0", + "@effect/sql-pglite": "4.0.0", "@effect/vitest": "4.0.0", - "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite": "0.5.8", "@types/node": "^22.10.2", "effect": "4.0.0", "expect-type": "^1.3.0", @@ -35,61 +36,63 @@ "@effect/platform-bun": ["@effect/platform-bun@4.0.0", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-g9O41Sub/+JKfLjvBNEoqR1Pg6wtc2bbYzv7Kw8YsMihbt/ypCn7ZoydEXaHvTTUvbVrXOQbVFlfplyo84Clug=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0", "undici": "^8.11.2" }, "peerDependencies": { "effect": "^4.0.0", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/L+4MUbl8FejPKLHLROYSOebvHnfixkP8KiblSD/DE5uH5xENZBHaux4nH6XMnRP+lr8ajpEJFS8sUrmmxFJeQ=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.22.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-VBXHJU9UXVZPdQGGDRjGXpo+TpOzRwZuMrdKAtIxi1qwcRftmacZ1zTgIrzBa0Z2RA2KVkoOecQUsTlt/nJZhw=="], "@effect/sql-clickhouse": ["@effect/sql-clickhouse@4.0.0", "", { "dependencies": { "@clickhouse/client": "^1.23.1" }, "peerDependencies": { "@effect/platform-node": "^4.0.0", "effect": "^4.0.0" } }, "sha512-KthAxSJAW4VR9YyFsgOwwWX2ym6aYMFEJkzyxJExzcGFiUbFMK7hsbwzagiBKJAr/N1gGWQYiZP2fZgkgruiuA=="], + "@effect/sql-pglite": ["@effect/sql-pglite@4.0.0", "", { "dependencies": { "@electric-sql/pglite": "^0.5.8" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-BTlCPSipKMHZJEoJ94rR4f8Gx8cv8tLpZeHc3sajg2ko/2GY8s6CyycSra3Z6GLFUhcivRTYQ9Zc5LThz64qfQ=="], + "@effect/vitest": ["@effect/vitest@4.0.0", "", { "peerDependencies": { "effect": "^4.0.0", "vitest": ">=5.0.0 <6.0.0" } }, "sha512-kGElCPFGtP+CgMCwV/HjJWqtE3zYo6I1+8MSA54l+EI24lPU9p2Efa+TBp1jfkg9j711KKzEqgSAy3VzTsb76Q=="], - "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.5.8", "", {}, "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - "@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="], + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], - "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], + "@quansync/fs": ["@quansync/fs@1.1.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg=="], - "@redis/bloom": ["@redis/bloom@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag=="], + "@redis/bloom": ["@redis/bloom@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA=="], - "@redis/client": ["@redis/client@6.2.1", "", { "dependencies": { "cluster-key-slot": "1.1.2" }, "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q=="], + "@redis/client": ["@redis/client@6.3.0", "", { "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg=="], - "@redis/json": ["@redis/json@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ=="], + "@redis/json": ["@redis/json@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw=="], - "@redis/search": ["@redis/search@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ=="], + "@redis/search": ["@redis/search@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw=="], - "@redis/time-series": ["@redis/time-series@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw=="], + "@redis/time-series": ["@redis/time-series@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A=="], - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.7", "", { "os": "android", "cpu": "arm" }, "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.7", "", { "os": "android", "cpu": "arm64" }, "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.7", "", { "os": "linux", "cpu": "arm" }, "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.7", "", { "os": "none", "cpu": "arm64" }, "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], @@ -101,9 +104,9 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -209,15 +212,13 @@ "@yuku-toolchain/types": ["@yuku-toolchain/types@0.8.7", "", {}, "sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA=="], - "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], + "ansis": ["ansis@4.4.0", "", {}, "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -229,7 +230,7 @@ "effect": ["effect@4.0.0", "", {}, "sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ=="], - "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="], "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], @@ -243,9 +244,9 @@ "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], - "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "hookable": ["hookable@6.1.2", "", {}, "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg=="], - "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "import-without-cache": ["import-without-cache@0.4.1", "", {}, "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -273,11 +274,9 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], - "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - - "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -289,21 +288,21 @@ "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "redis": ["redis@6.2.1", "", { "dependencies": { "@redis/bloom": "6.2.1", "@redis/client": "6.2.1", "@redis/json": "6.2.1", "@redis/search": "6.2.1", "@redis/time-series": "6.2.1" } }, "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg=="], + "redis": ["redis@6.3.0", "", { "dependencies": { "@redis/bloom": "6.3.0", "@redis/client": "6.3.0", "@redis/json": "6.3.0", "@redis/search": "6.3.0", "@redis/time-series": "6.3.0" } }, "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "rolldown": ["rolldown@1.2.7", "", { "dependencies": { "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.7", "@rolldown/binding-android-arm64": "1.2.7", "@rolldown/binding-darwin-arm64": "1.2.7", "@rolldown/binding-darwin-x64": "1.2.7", "@rolldown/binding-freebsd-x64": "1.2.7", "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", "@rolldown/binding-linux-arm64-gnu": "1.2.7", "@rolldown/binding-linux-arm64-musl": "1.2.7", "@rolldown/binding-linux-ppc64-gnu": "1.2.7", "@rolldown/binding-linux-s390x-gnu": "1.2.7", "@rolldown/binding-linux-x64-gnu": "1.2.7", "@rolldown/binding-linux-x64-musl": "1.2.7", "@rolldown/binding-openharmony-arm64": "1.2.7", "@rolldown/binding-win32-arm64-msvc": "1.2.7", "@rolldown/binding-win32-x64-msvc": "1.2.7" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig=="], + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.14", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.8.0", "yuku-codegen": "^0.8.0", "yuku-parser": "^0.8.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -311,7 +310,7 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -321,13 +320,13 @@ "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], - "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "verkit": ["verkit@0.3.2", "", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="], - "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], @@ -341,14 +340,6 @@ "yuku-parser": ["yuku-parser@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7", "yuku-ast": "^0.8.7" }, "optionalDependencies": { "@yuku-parser/binding-android-arm64": "0.8.7", "@yuku-parser/binding-darwin-arm64": "0.8.7", "@yuku-parser/binding-darwin-x64": "0.8.7", "@yuku-parser/binding-freebsd-x64": "0.8.7", "@yuku-parser/binding-linux-arm-gnu": "0.8.7", "@yuku-parser/binding-linux-arm-musl": "0.8.7", "@yuku-parser/binding-linux-arm64-gnu": "0.8.7", "@yuku-parser/binding-linux-arm64-musl": "0.8.7", "@yuku-parser/binding-linux-x64-gnu": "0.8.7", "@yuku-parser/binding-linux-x64-musl": "0.8.7", "@yuku-parser/binding-win32-arm64": "0.8.7", "@yuku-parser/binding-win32-x64": "0.8.7" } }, "sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ=="], - "@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="], - "@effect/sql-clickhouse/@clickhouse/client": ["@clickhouse/client@1.23.1", "", {}, "sha512-vs3/Zc1dHvT171btW5nMoPsPCJ6QVJ5pp7obxzO5sjqwFx/jjz9wwCAqcFOdc2DhprugDBaVn+4dVY8hG3A9nw=="], - - "@types/ws/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], - - "@effect/platform-node/@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - - "@types/ws/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], } } diff --git a/design/transactions.md b/design/transactions.md new file mode 100644 index 0000000..b8ffda5 --- /dev/null +++ b/design/transactions.md @@ -0,0 +1,648 @@ +# Transactions: one primitive over Effect's SqlClient + +Status: phases 1 to 3 built (`@maple-dev/effect-orm/database`, see `docs/database.md`). Section 9 lists +where the build differs from the plan. Phases 4 and 5 are not started. + +## Goal + +Give effect-orm a transaction primitive that a Postgres application can use instead of +Drizzle's `db.transaction`, built on Effect's own `SqlClient.withTransaction` rather than a +second implementation of connection pinning. One API across dialects: Postgres implements it +fully, ClickHouse says plainly that it does not, and a later dialect (MySQL, SQLite) declares +what it supports through a capability on `Dialect`, the way `Dialect.clauses` already works. + +The first consumer is Maple, which wants to leave Drizzle for Postgres. Section 5 is honest +about the other things that move needs: transactions are not the last blocker. + +Everything below was read from source at the installed versions: `effect@4.0.0` +(`effect/sql/SqlClient.ts`, `SqlConnection.ts`, `SqlError.ts`, `Migrator.ts`), +`@effect/sql-pg@4.0.0`, `@effect/sql-pglite@4.0.0`, `@effect/sql-clickhouse@4.0.0`, and +`drizzle-orm@1.0.0-rc.5-5935859` (`effect-postgres/session.js`, `pg-core/effect/session.js`) +as installed in Maple. ClickHouse claims were checked against a live +`clickhouse/clickhouse-server:26.8.2.7` (section 3). + +## 1. How Effect's `withTransaction` works + +`SqlClient.make` builds `withTransaction` from `makeWithTransaction` (SqlClient.ts:372). + +**Connection pinning through context.** Each client gets its own context key, +`TransactionConnection(clientId)` (`effect/sql/SqlClient/TransactionConnection/`), exposed +as `sql.transactionService`. Its value is `[connection, depth]`. Every statement resolves its +connection through `getConnection` (SqlClient.ts:180): the transaction connection if the key +is in the fiber's context, otherwise the pool's `acquirer` (or `borrower`). So any statement +built from **the same client instance** inside the transaction's effect runs on the +transaction's connection, with no argument threading. A statement from a different client +instance (a second pool) does not see the key and runs outside the transaction. + +**Acquisition.** A top-level transaction opens a fresh `Scope`, takes a connection from +`transactionAcquirer` (a reserved pool connection for `@effect/sql-pg`, `pool.reserve`; a +semaphore permit on the single connection for `@effect/sql-pglite`), runs `BEGIN`, and closes +the scope (returning the connection) after COMMIT or ROLLBACK. + +**Nesting.** If the key is already present, the call reuses that connection with +`id = depth + 1` and runs `SAVEPOINT effect_sql_` instead of BEGIN. On success it does +nothing for a savepoint (the outer COMMIT covers it); on failure it runs +`ROLLBACK TO SAVEPOINT effect_sql_`. Either way it runs `RELEASE SAVEPOINT` when the +client configured `releaseSavepoint` (`@effect/sql-pg` and `@effect/sql-pglite` both do). +Nested calls under one transaction are serialized by a per-transaction semaphore, so two +concurrent nested blocks cannot interleave their savepoints. Plain statements inside a +transaction are not serialized; on Postgres they queue on the one connection. + +**Failure and interruption.** The whole wrapper is `Effect.uninterruptibleMask`; only the +body is `restore`d. Acquisition and BEGIN cannot be interrupted halfway, and an interrupted +body still reaches the exit handler, which rolls back. Any non-success exit (typed failure, +defect, interruption) rolls back. + +**Commit and rollback failures are defects.** The exit handler wraps COMMIT, ROLLBACK and +the savepoint statements in `Effect.orDie` (SqlClient.ts:427-449). A failed COMMIT (a +deferred constraint, a serialization failure detected at commit, `@effect/sql-pg`'s +"COMMIT rolled back an aborted transaction") reaches the caller as a die, not as a +`SqlError` in the error channel. A failed ROLLBACK also replaces the body's original +failure: the handler returns `Effect.flatMap(rollback, () => exit)`, so when the rollback dies +the original exit is dropped. Maple already works around the first of these +(`absorbDriverErrors`, `packages/backend/src/platform/DatabaseLive.ts:104-127`). + +**Isolation level and access mode: not supported by Effect.** The BEGIN text is fixed per +client (`beginTransaction`, default `"BEGIN"`; ClickHouse passes `"BEGIN TRANSACTION"`). +`withTransaction` takes no options. The only way to choose an isolation level on Postgres is +a `SET TRANSACTION ...` statement as the first statement inside the transaction, which +Postgres accepts until the first query of the transaction. + +**Tracing.** A `sql.transaction` span with `db.transaction.commit`, +`db.transaction.savepoint` and `db.transaction.rollback` events; statements inside are +children of it. + +**`Migrator.ts`** runs all pending migrations inside one `sql.withTransaction(run)` +(Migrator.ts:315), which is why it cannot be used for ClickHouse (see `design/migrations.md`). + +### Drizzle's effect transaction, compared + +Drizzle's `effect-postgres` session does not implement transactions itself +(`effect-postgres/session.js:25-39`): + +```js +transaction(transaction, config) { + return this.client.withTransaction(Effect.gen(function* () { + const tx = new EffectPgTransaction(dialect, this, relations) + if (config) for (const statement of tx.getTransactionConfigStatements(config)) yield* this.client.unsafe(statement) + return yield* transaction(tx) + })) +} +// nested: EffectPgTransaction.transaction(cb) => this.session.transaction(cb) (no config) +``` + +| | Effect `withTransaction` | Drizzle `db.transaction` | +| --- | --- | --- | +| BEGIN / COMMIT / savepoints | Its own | Delegates to Effect's | +| Connection pinning | Context key per client | Same (queries go through `client.unsafe`) | +| Isolation, access mode, deferrable, snapshot | None | `SET TRANSACTION ...` after BEGIN, top level only | +| Nested options | n/a | Silently dropped | +| Explicit rollback | Fail the effect | `yield* tx.rollback()` returns an `EffectTransactionRollbackError` that propagates to the caller | +| Commit failure | Defect | Defect (inherited) | +| Logging of config statements | n/a | Bypasses Drizzle's logger | + +So Drizzle's effect transaction is Effect's transaction plus a `SET TRANSACTION` line and a +`tx` handle. That is the right shape to copy, minus the silent option drop and the +unlogged statement. + +## 2. What Maple uses today + +From a survey of `~/Documents/GitHub/maple` (all sites in `packages/backend/src`): + +- **33 production transactions**, each `database.execute((db) => db.transaction((tx) => ...))` + or through `makeDbExecute` (`platform/db-execute.ts`). None nested, none with an isolation + level, access mode or deferrable option, no savepoints, no `tx.rollback()`. Rollback is + always "fail the Effect with a domain `Schema.TaggedError`". Everything runs at READ + COMMITTED. +- **Contention retry outside the transaction**: `makeDbExecute` retries SQLSTATE 40001 and + 40P01 up to 3 times with exponential backoff from 50 ms, re-running the whole transaction. +- **Inside transactions**: claim-style `UPDATE ... RETURNING` then branch on row count; + `INSERT ... ON CONFLICT DO UPDATE / DO NOTHING ... RETURNING`; `SELECT ... FOR UPDATE` and + `FOR SHARE`; raw `select pg_advisory_xact_lock(hashtext(...))`; raw bulk `UPDATE ... FROM + (VALUES ...)`; `.returning(txidColumn)` where `txidColumn` is + `pg_current_xact_id()::xid::text` (Electric sync). +- **Helpers are transaction-agnostic**: they take `tx: MapleDbLike`, the same type as the + database, so they run inside or outside a transaction. +- **Request-scoped pool** (`platform/pg-connection-scope.ts`): `withPgConnectionScope` installs + a `PgConnectionScope` `Context.Reference` per invocation; the pool (max 5 connections) is + opened lazily, closed when the invocation ends, and a call after close fails with + `PgConnectionScopeClosedError` instead of dialing again. `Database.execute` reads the + reference at call time. `forkRequestScoped` forks into the request `Scope` so forked work + cannot outlive the pool. CLAUDE.md: "Request-scoped context (transactions, tenant, actor) + stays on the invocation, never captured at construction" and "One pool per invocation via + `withPgConnectionScope`; connections never outlive the invocation." +- **Errors and spans** (`platform/DatabaseLive.ts`): driver errors and driver defects + (including the orDie'd COMMIT) are absorbed into one `DatabaseError`; domain errors pass + through. A statement collector puts every statement of a call, including a whole + transaction, into one span, and the `@effect/sql` per-statement and `sql.transaction` spans + are switched off. +- **Tests**: `@effect/sql-pglite` plus `drizzle-orm/effect-pglite` + (`packages/db/src/pglite.ts`), with tests for a failed deferred-FK COMMIT becoming + `DatabaseError`, one span per transaction, and domain failures passing through. + +What a replacement must preserve: transactions keyed off the per-invocation client, never a +client captured at construction; helpers that do not care whether they run inside a +transaction; domain errors untouched; a failed COMMIT as a typed error; whole-transaction +retry on contention; one observable unit per transaction. + +## 3. ClickHouse: what a transaction can and cannot guarantee + +Checked on `clickhouse/clickhouse-server:26.8.2.7` over HTTP. + +**Default server: no transactions.** `BEGIN TRANSACTION` fails with +`Code: 48 ... Transactions are not supported. (NOT_IMPLEMENTED)`, with or without a session. +So `ClickhouseClient.withTransaction` fails at BEGIN on any normal deployment. + +**With `allow_experimental_transactions` and Keeper configured**, inside an HTTP session +(`session_id`), and with `async_insert=0` (26.8 defaults inserts to async, which a +transaction refuses with `Async inserts inside transactions are not supported`): + +| Behavior | Result | +| --- | --- | +| INSERT then ROLLBACK on MergeTree | Rolled back | +| INSERT then COMMIT | Visible after commit | +| `ALTER TABLE ... DELETE` (mutation) then ROLLBACK | Rolled back | +| A Memory table in the transaction | `Storage Memory ... does not support transactions` | +| `CREATE TABLE` inside | `Transactions are not supported for this type of query` | +| `SAVEPOINT` | Syntax error: no savepoints | +| `BEGIN` inside a transaction | `Nested transactions are not supported` | +| Any failed statement | Transaction is poisoned; everything but ROLLBACK fails, COMMIT says `Transaction is not in RUNNING state` | +| Two concurrent requests on one session | `SESSION_IS_LOCKED` | +| Session expires before COMMIT | Transaction silently rolled back; COMMIT then fails with `There is no current transaction` | +| Reader in another transaction | Does not see uncommitted rows (snapshot) | +| Reader outside any transaction | A plain `SELECT count()` **did** see an uncommitted row | +| Concurrent mutations on the same part | Second one fails with a serialization error; its COMMIT then fails | +| Isolation choice | None; snapshot only (`SET TRANSACTION SNAPSHOT n` exists) | + +**The dangerous case is a stateless BEGIN.** On a server with transactions enabled, a +`BEGIN TRANSACTION` with no `session_id` *succeeds*, the transaction dies with the request, +the following INSERT autocommits, and `COMMIT` / `ROLLBACK` fail with +`There is no current transaction`. `@effect/sql-clickhouse` has no session either: its +acquirer is one stateless HTTP connection (`Effect.succeed(connection)`). Its +`withTransaction` is saved from this today only by accident. BEGIN goes through the query +path, which appends `FORMAT JSON`, and `BEGIN TRANSACTION FORMAT JSON` is a syntax error on +any server. Wrapped in `asCommand`, BEGIN reaches the server: a default server refuses it, and +one with transactions enabled accepts and forgets it, so the body's writes land immediately +and the COMMIT failure becomes a defect after the data is written. +`tests/database.clickhouse.test.ts` pins both BEGIN paths on the CI matrix (26.2 and 26.8). + +**Decision.** The ClickHouse dialect declares `transactions: { support: "none" }`, and +`transaction` under it fails with `TransactionUnsupported` before sending anything. Pretending +parity would turn the stateless BEGIN above into silent partial writes. An experimental +opt-in is possible later (section 7, phase 5) but it needs all of: a dedicated client with +its own `session_id` per transaction, statements strictly serialized on it, `async_insert=0`, +MergeTree-family tables only, no DDL, no savepoints (nesting fails), a server with the +experimental flag and Keeper, and an explicit acknowledgement that readers outside a +transaction can see uncommitted rows. It would be a separate capability value +(`"experimental-session"`) that the caller must request by name, never the default. + +## 4. The proposed primitive + +### 4.1 Shape + +A new subpath, `@maple-dev/effect-orm/database`, with one service that executes compiled +statements and runs transactions. The library still never opens a connection: the service is +built from a `SqlClient` the caller already has, exactly like `MigrationDriver`. + +```ts +import { Database } from "@maple-dev/effect-orm/database" + +export interface DatabaseApi { + readonly dialect: Dialect + /** Run a compiled SELECT (or a write with RETURNING) and decode its rows. */ + readonly run: (compiled: CompiledQuery) => Effect.Effect, DatabaseError | CompiledQueryDecodeError> + /** Run a statement whose rows are not decoded: raw SQL, DDL, an advisory lock. */ + readonly execute: (statement: Statement) => Effect.Effect + /** Run `body` in a transaction; nested calls become savepoints. Provides `Database.Transaction`. */ + readonly transaction: ( + body: Effect.Effect, + options?: TransactionOptions, + ) => Effect.Effect> + /** Re-run `effect` on serialization failure or deadlock (SQLSTATE 40001 / 40P01). */ + readonly retryContention: (effect: Effect.Effect, options?: RetryOptions) => Effect.Effect +} + +export class Database extends Context.Service()("@maple-dev/effect-orm/Database") {} + +Database.fromSqlClient(sql, { dialect: postgresDialect }) // DatabaseApi +Database.layerSqlClient({ dialect: postgresDialect }) // Layer +Database.run(compiled) / Database.execute(s) / Database.transaction(body, options) // read the service +Database.Transaction // service, present only inside a transaction +Database.retryContention(effect) // standalone retry combinator +``` + +The name `Database` is settled. Maple has its own `Database` service and aliases this one on +import (`import { Database as Orm } from "@maple-dev/effect-orm/database"`). + +`Statement` is `{ sql: string; parameters: ReadonlyArray }`, which `CompiledQuery` +already satisfies, so raw SQL and compiled queries share one path. + +### 4.2 UX: context only, shaped like Effect's own transactions + +Effect 4 has two transaction APIs, and both pass the transaction through context with no +handle: + +- **`sql.withTransaction(effect)`**: a plain wrapper. Statements inside find the connection + in context. The type does not change (`R` in, `R` out), so nothing in the types says + whether a helper needs a transaction. +- **`Effect.tx(effect)`** (in-memory STM over `TxRef`, Effect.ts:24708): also a wrapper, + and also composes when nested (an inner `tx` joins the outer one). Two details are worth + copying. Every `TxRef` operation wraps *itself* in `Effect.tx`, so it works alone or + inside a bigger transaction, and nobody threads a handle. And the transaction state is a + real service, `Effect.Transaction`, with `tx` typed as + `Effect => Effect>`. So a function that + `yield*`s `Effect.Transaction` carries it in `R`, and the compiler will not run it until + something wraps it in `tx`. + +effect-orm takes both ideas: + +1. **No handle.** `Database.run` and `Database.execute` work inside or outside a + transaction, like `TxRef.get`. Helpers keep one signature, which is what Maple's + `MapleDbLike` helpers rely on today. +2. **"Must be atomic" is a requirement in `R`.** `Database.Transaction` is a service that + only `Database.transaction` provides. Its value describes the open transaction + (`depth`, `isolationLevel`, `accessMode`), so it replaces a separate + `transactionDepth`. `transaction` returns + `Effect>`. + A helper that is only correct inside a transaction `yield*`s it, and a caller that forgets + the wrapper gets a type error. A runtime `requireTransaction` check is not needed. +3. **Pipeable and dual**, like `Effect.withSpan`. `Database.transaction(effect, options?)` + and `effect.pipe(Database.transaction(options?))` both work. The data-last form also fits + `Effect.fn`'s trailing pipe arguments, so a service method is declared transactional + where it is defined, the same way `Effect.fn` methods get spans. + +```ts +// Correct alone, correct inside a transaction: no Database.Transaction in R. +const insertApiKey = (row: NewApiKey) => Database.run(Q.insertApiKey(row)) + +// Only correct inside a transaction: Database.Transaction is in R. +const revokeRefreshFamily = Effect.fn("revokeRefreshFamily")(function* (familyId: string) { + yield* Database.Transaction + yield* Database.run(Q.revokeFamily(familyId)) + yield* Database.run(Q.revokeFamilyKeys(familyId)) +}) + +// A transactional service method. The trailing pipe argument removes the requirement. +const rotate = Effect.fn("McpOAuth.rotate")( + function* (token: string) { + const claimed = yield* Database.run(Q.claimRefreshToken(token)) // UPDATE ... RETURNING + if (claimed.length === 0) { + yield* revokeRefreshFamily(familyOf(token)) // would roll back with the failure below; see note + return "reused" as const + } + yield* insertApiKey(newKey) + return "rotated" as const + }, + Database.transaction({ retry: "contention" }), +) + +// Calling revokeRefreshFamily(id) outside a transaction is a compile error: +// Type 'Database.Transaction' is not assignable to type 'never'. +``` + +(The note: in Maple, a reused token revokes the family and *returns* `"reused"` rather than +failing, so the revocation commits. Rolling back is always "fail the effect", as today.) + +A transaction on one `Database` removes the requirement for helpers on any `Database`. That +is only wrong for an application with two transactional databases in one fiber, which is not +a case Maple has; per-database marker types can come later if one does. + +### 4.3 Built on `withTransaction`, not beside it + +`transaction` is `sql.withTransaction(body')` plus three things Effect does not do: +options, typed commit errors, and a closed-transaction guard. It does not reserve its own +connection or issue its own BEGIN, for one decisive reason: a nested `sql.withTransaction` +reads a per-transaction semaphore from a context key private to `makeWithTransaction` +(`Context.getUnsafe(services, transactionSemaphore)`). A hand-rolled top-level transaction +that only provides `sql.transactionService` would make any nested `withTransaction` (from +user code, a library, or Drizzle during a gradual migration) throw. Delegating keeps +effect-orm transactions, raw `sql\`...\`` statements and other `SqlClient` users on one +connection and one nesting counter. + +Because pinning travels in context, `run` and `execute` are transaction-aware for free: they +call `sql.withoutTransforms().unsafe(statement.sql, statement.parameters)` on the same client, +which picks up the transaction connection. (`withoutTransforms` keeps result names exactly as +the compiled decoder expects; `docs/running-queries.md` already asks callers to disable name +transforms.) ClickHouse statements without rows go through the `command` wrapper, as in +`MigrationDriver`. + +The depth in `Database.Transaction` comes from `Effect.serviceOption(sql.transactionService)`, +so it stays correct when an outer `sql.withTransaction` (or Drizzle) opened the transaction. + +### 4.4 Options + +```ts +export interface TransactionOptions { + readonly isolationLevel?: "read committed" | "repeatable read" | "serializable" + readonly accessMode?: "read write" | "read only" + /** Postgres: only meaningful with serializable + read only. */ + readonly deferrable?: boolean + /** Re-run the whole transaction on serialization failure or deadlock. Top level only. */ + readonly retry?: "contention" | { readonly schedule: Schedule.Schedule } +} +``` + +- Options become one statement, built by the dialect (`transactions.setTransaction`), run as + the first statement inside `withTransaction`: + `SET TRANSACTION ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE`. It goes through + `execute`, so it is traced and observed like any other statement (Drizzle's bypasses its + logger). +- `read uncommitted` is left out: Postgres runs it as read committed, and offering a level + that does not exist is the kind of parity this plan avoids. A dialect lists the levels it + accepts; an unlisted one fails with `TransactionOptionsRejected` before BEGIN. +- **Nested calls with options fail** with `TransactionOptionsRejected` before the savepoint. + Postgres cannot change isolation once the transaction has run a query, and Drizzle's + silent drop hides a real bug. Nested `retry` is refused for the same reason: after a + serialization failure the whole transaction is aborted, so retrying a savepoint cannot + succeed. +- `snapshot` (Drizzle's `set transaction snapshot`) is out until a consumer needs it. + +### 4.5 Errors + +New `Schema.TaggedError`s, namespaced like the migration errors: + +| Error | When | +| --- | --- | +| `@maple-dev/effect-orm/DatabaseError` | A statement failed: wraps the `SqlError` (`cause`), with `message`, `sql`, and `reason` copied from `SqlError.reason._tag` (`SerializationError`, `DeadlockError`, `UniqueViolation`, ...) so callers can branch without digging | +| `@maple-dev/effect-orm/TransactionUnsupported` | The dialect has no transactions (ClickHouse) | +| `@maple-dev/effect-orm/TransactionOptionsRejected` | An option the dialect does not support, or options on a nested call | +| `@maple-dev/effect-orm/TransactionCommitFailed` | COMMIT failed; carries `reason` like `DatabaseError` | +| `@maple-dev/effect-orm/TransactionRollbackFailed` | ROLLBACK failed; carries the body's original `Cause` as well as the rollback error | +| `@maple-dev/effect-orm/TransactionClosed` | A statement ran with a transaction's context after that transaction ended (4.7) | + +`TransactionError` is the union of the transaction ones. A compiled query whose dialect does +not match the database's (needs a `CompiledQuery.dialect` name, added in phase 1) fails as +`DatabaseError` with reason `DialectMismatch` before sending. + +**Recovering typed commit and rollback errors.** Effect dies on a failed COMMIT or ROLLBACK +(section 1). `transaction` turns those back into typed errors without guessing: + +1. The body runs as `body.pipe(Effect.catchCause(...))` that re-fails any defect from the + body as a private `BodyDefect` failure (still a failure, so Effect still rolls back) and + records whether the body succeeded. +2. Outside `withTransaction`, a `BodyDefect` is turned back into the original die. Any other + die whose defect is a `SqlError` came from transaction control: COMMIT if the body + succeeded, ROLLBACK otherwise. +3. An interrupted transaction whose ROLLBACK died stays interrupted (Maple's rule). + +This is a translation of Effect 4.0.0 behavior. The right fix is upstream: COMMIT failure as a +`SqlError` in the error channel, and ROLLBACK failure that keeps the original cause. Filing +that is part of phase 2; when it lands, steps 1 and 2 become a no-op. Decided: ship the +translation and file upstream in parallel, not one after the other. + +**Retry.** `Database.retryContention(effect, options?)` re-runs `effect` when it fails with +`DatabaseError` or `TransactionCommitFailed` whose reason is `SerializationError` or +`DeadlockError`, three times with exponential backoff from 50 ms by default, matching +`makeDbExecute`. Domain errors are never retried. It works on single statements outside a +transaction too. Inside an open transaction it refuses to retry (fails with +`TransactionOptionsRejected`), because after a serialization failure the whole transaction is +aborted and only the outermost boundary can start again. `transaction(body, { retry: +"contention" })` is shorthand for `retryContention(transaction(body))`, so each attempt gets a +fresh BEGIN. Decided: both forms. + +### 4.6 Interruption + +Inherited from Effect: BEGIN and connection acquisition are uninterruptible, the body is +interruptible, and interruption rolls back before the connection is returned. Nothing extra. +Two points the docs must state: + +- A fiber forked inside the transaction inherits the transaction connection. If it outlives + the transaction, its statements would run on a connection that has gone back to the pool, + possibly inside someone else's transaction. Join forks before the body returns. In Maple + terms: never `forkRequestScoped` from inside a transaction. +- Concurrent statements inside one transaction (`Effect.all(..., { concurrency })`) share one + connection and queue on it; a failure in one aborts the transaction for all of them. + +### 4.7 The closed-transaction guard + +`transaction` also provides a small private reference holding `{ open: boolean }`, set to +false in the exit handler. `run` and `execute` check it when `sql.transactionService` is in +context: a statement carrying a closed transaction's context fails with `TransactionClosed` +instead of running on a recycled connection. This is the effect-orm equivalent of Maple's +`PgConnectionScopeClosedError` for the transaction level. It only covers statements that go +through `Database`; a raw `sql\`...\`` on the client is not guarded. + +### 4.8 Dialect capability + +```ts +export interface DialectTransactions { + /** `none`: `transaction` fails with TransactionUnsupported. */ + readonly support: "none" | "full" + /** Nested `transaction` calls become savepoints. Without it, nesting fails. */ + readonly savepoints: boolean + readonly isolationLevels: ReadonlyArray + readonly accessModes: boolean + readonly deferrable: boolean + /** The statement that applies `options` as the first statement of a transaction. */ + readonly setTransaction: (options: TransactionOptions) => string | undefined +} + +export interface Dialect extends SqlSyntax { + // ... + readonly transactions: DialectTransactions +} +``` + +- `postgresDialect`: `full`, savepoints, the three levels, access modes, deferrable, + `SET TRANSACTION ...`. +- `clickhouseDialect`: `none`. A later `"experimental-session"` value would be added only with + the session-bound executor from section 3. +- MySQL later: `full`, savepoints, four levels; it needs `SET TRANSACTION` **before** BEGIN, + so the hook may need a `placement: "before-begin" | "after-begin"`, which Effect's + `withTransaction` cannot do for a pooled client. That is the point to revisit, not now. +- SQLite later: `full`, savepoints, no isolation levels (it is serializable), access mode via + `BEGIN DEFERRED/IMMEDIATE` rather than options. + +Capability is runtime, checked before any statement. A type-level split (no `transaction` +method on a ClickHouse database) was considered and deferred: the `Dialect` value is not a +literal type today, so it would need a generic on `Database`, and it can be added later +without breaking callers. + +### 4.9 Relation to `MigrationDriver` + +Share the core, not the interface. `MigrationDriver` is deliberately tiny (raw text, rows as +records) and assumes nothing is atomic; that is right for ClickHouse and should stay. The +transaction logic lives in one internal function (`src/database/transaction.ts`) that both +use: + +- `Database.transaction` calls it. +- Postgres migrations (migrations phase 6: "one transaction per migration, + `pg_advisory_xact_lock`") add an optional `transaction` member to `MigrationDriverApi`, + filled by `fromSqlClient` when the dialect supports it. The runner wraps each migration in + it when present, and keeps the step journal when absent. +- `MigrationDriver.fromDatabase(db)` adapts a `Database` so a consumer builds one thing. + +### 4.10 How Maple would wire it + +Maple builds its `PgClient` per invocation inside `withPgConnectionScope`. `Database` is a +plain object over that client (`Database.fromSqlClient(pgClient, { dialect })`), so it is built +per invocation in the same place `makeMapleEffectDb` is today, and the transaction connection +lives in fiber context, never in a service. Nothing request-scoped is captured at +construction. During a gradual migration both Drizzle and effect-orm can run over the same +`PgClient`: they share `sql.transactionService`, so a Drizzle `db.transaction` and an +effect-orm `Database.run` inside it hit the same connection. + +### 4.11 Tracing and observation + +An `effect_orm.transaction` span with `db.transaction.isolation_level`, +`db.transaction.access_mode`, `effect_orm.transaction.depth` and +`effect_orm.transaction.attempt`, around Effect's own `sql.transaction`. An optional +`observe: (statement) => Effect` on `fromSqlClient` lets a consumer collect every +statement, including `SET TRANSACTION`, which is what Maple's statement collector needs. + +## 5. What else blocks replacing Drizzle in Maple + +Transactions are **not** the last blocker. Today effect-orm compiles SELECTs only. + +| Needed by Maple | Maple usage (approx.) | effect-orm today | +| --- | --- | --- | +| `INSERT`, incl. multi-row | ~101 | None | +| `UPDATE` with `SET` expressions (`count + 1`) | ~120 | None | +| `DELETE` | ~79 | None | +| `RETURNING` (columns, expressions such as the txid) | 118 | None | +| `ON CONFLICT (target) DO UPDATE SET ... excluded.x` / `DO NOTHING`, `setWhere` | 25 / 43, 66 `excluded.` refs, 6 `setWhere` | None | +| `SELECT ... FOR UPDATE / FOR SHARE / SKIP LOCKED` | 7 | None | +| `SELECT DISTINCT` | 10 | None | +| Inner / left joins, `offset`, `having`, subqueries | 15 / 6 / 8 | Yes | +| `sql` template interop, `sql.join`, `sql.raw` | ~163, 2, 2 | `rawExpr`; no Drizzle-style template in statements | +| jsonb operators, `@>` with a typed param | ~12 | `->>` only | +| Raw statements (advisory locks, bulk `UPDATE ... FROM (VALUES ...)`) | 6 | `execute` in this plan | +| `timestamp` columns as JS `Date` (`mode: "date"`) | 226 columns | `PG.timestamptz` decodes to `DateTime.Utc`; either a `Date` codec or call-site changes | +| `jsonb` with `$type` | 50 columns | `PG.jsonb()`; needs a schema-typed variant | +| Table definitions | 68 `pgTable`, 90 indexes, 47 unique, ~2 FKs | Query-side `table()` exists; Postgres `defineTable` DDL does not | +| Migrations | 75 drizzle-kit folders, applied by the prd alchemy deploy; PGlite tests use Drizzle's migrator | ClickHouse only; Postgres is migrations phase 6, not started | + +Not needed: relational queries (`db.query.*`), CTEs, `$count`, `alias`, `db.batch`, enums, +checks, views, RLS. + +**Migrations are separable.** The runtime switch does not require effect-orm migrations: +drizzle-kit can keep authoring and applying `packages/db/drizzle` (the alchemy deploy and the +PGlite test migrator both work on the folder, not on the ORM), while query code moves to +effect-orm. Removing drizzle-kit is a later, independent step. + +**Order of work for Maple**, by what unblocks the most call sites: write builders +(INSERT / UPDATE / DELETE with RETURNING) first, then ON CONFLICT, then the transaction +primitive (only 33 sites, but each needs the write builders anyway), then `FOR UPDATE`, +DISTINCT and the `Date` codec. The write builders are their own design note +(`design/writes.md`); this plan only fixes the interface they must meet: a write compiles to a +`CompiledQuery` whose `decodeRows` decodes the RETURNING list (empty schema without one), so +`Database.run` executes writes and reads alike. + +## 6. Tests + +**Postgres, in process.** Add `@effect/sql-pglite@4.0.0` as a dev dependency and bump the +direct `@electric-sql/pglite` dev dependency from 0.3.15 to the same 0.5 line, so +`src/pg/postgres.test.ts` and the transaction suite run on one build. Every test builds `Database.fromSqlClient(pgliteClient)`, so it +exercises the real `withTransaction`, not a fake: + +- commit makes writes visible; a typed failure rolls back and passes through unchanged; +- a defect in the body rolls back and stays the same defect (not `TransactionCommitFailed`); +- interruption mid-body rolls back (fork, interrupt, assert nothing written, connection free); +- nested: inner failure caught by the outer keeps outer writes and drops inner ones; + inner success is kept; three levels deep; concurrent nested blocks are serialized; +- nested call with options fails with `TransactionOptionsRejected` and sends no savepoint; +- `SET TRANSACTION` is the first statement; `current_setting('transaction_isolation')` and + `transaction_read_only` read back inside; a write under `read only` fails; +- a deferred FK violation fails at COMMIT as `TransactionCommitFailed`, not a defect (this + mirrors Maple's `DatabaseLive.test.ts`); +- `retry: "contention"` re-runs on a forced 40001 and never on a domain error; +- a forked fiber that outlives the transaction gets `TransactionClosed`; +- a Drizzle-free raw `sql.withTransaction` nested inside `Database.transaction`, and the + reverse, share one connection and nest correctly; +- `run` decodes rows from a compiled query inside and outside a transaction, and refuses a + ClickHouse-compiled query. + +**Postgres over the wire.** PGlite has one connection, so it cannot show isolation between +two transactions or real serialization failures. Add a small `@effect/sql-pg` suite against a +Postgres container (the CI service pattern the ClickHouse matrix uses): two concurrent +serializable transactions produce 40001 and the retry resolves it; READ COMMITTED does not +see an uncommitted write from another connection. + +**ClickHouse matrix.** `Database.transaction` under the ClickHouse dialect fails with +`TransactionUnsupported` and sends nothing (assert via `observe`). `run` and `execute` work +against every matrix server. One test documents the stateless-BEGIN hazard from section 3 on a +default server (BEGIN refused), so a future server or driver change that alters it is noticed. + +**Types.** `.test-d.ts`: the error channel of `transaction` is `E | DatabaseError | +TransactionError`; `run` infers the row type from the compiled query; a helper that +`yield*`s `Database.Transaction` fails to type-check until wrapped, and `transaction` removes +the requirement both data-first and as an `Effect.fn` pipe argument. + +## 7. Phases + +1. **Execution seam.** `@maple-dev/effect-orm/database`: `Database` service, + `fromSqlClient`, `layerSqlClient`, `run`, `execute`, `DatabaseError`, `observe`. + `CompiledQuery.dialect`. Docs page; `running-queries.md` points to it. No transactions yet. +2. **Transactions on Postgres.** `Dialect.transactions`, `transaction` over `withTransaction`, + options, typed commit and rollback errors, closed guard, retry, spans. PGlite suite and the + wire suite. File the upstream Effect issue for orDie'd COMMIT and ROLLBACK. +3. **ClickHouse declared unsupported.** `clickhouseDialect.transactions = { support: "none" }`, + the matrix tests, a docs section with the table from section 3. +4. **Migrations share the core.** Optional `MigrationDriverApi.transaction`, + `MigrationDriver.fromDatabase`; used by migrations phase 6 when it starts. +5. **Maybe: ClickHouse experimental sessions.** Only if a consumer asks. A session-bound + executor (own client with `session_id`, serialized, `async_insert=0`) and the + `"experimental-session"` capability, behind an explicit opt-in. + +Write builders, ON CONFLICT, locking clauses and the `Date` codec (section 5) are separate +plans and can run in parallel with phases 1 to 3. + +## 8. Open questions + +Decided: + +- **Name**: `@maple-dev/effect-orm/database`, `Database` service; Maple aliases it. +- **No `tx` handle**: context only, with "must be atomic" as `Database.Transaction` in `R` + (4.2). +- **Commit and rollback errors**: translate in effect-orm and file upstream in parallel. +- **Retry**: `Database.retryContention` plus the `retry` option as shorthand. +- **ClickHouse capability**: checked at runtime (`TransactionUnsupported`), one `Database` + type for every dialect. A `Database` split can come later without breaking callers. +- **PGlite**: align the existing direct PGlite 0.3.15 tests on the 0.5 line that + `@effect/sql-pglite` brings (phase 2), so one build runs every Postgres test. +- **`outsideTransaction`**: not now. Maple has no write that must survive a rollback; add it + when a consumer needs one. + +Nothing is open. Phase 1 can start. + +## 9. Implementation notes (phases 1 to 3) + +What landed differently from the sections above: + +- **No compile step, no raw `$n`.** `run` takes the built query (or a union, or a query + compiled elsewhere) and compiles it with the database's dialect, so callers never choose a + `compile` and never see `compileUnsafe`; compile failures are a typed `QueryBuilderError`. + Statements the builder lacks are written with `Db.sql\`...\``: values are bound per dialect + at run time (`$n` for Postgres, escaped literals for ClickHouse), templates compose, and + `sql.identifier` quotes plain names only. `query` takes an optional row schema so a + `RETURNING` read comes back typed. +- **`requireTransaction` instead of `yield* Db.Transaction`.** Section 4.2's marker was a bare + `yield*` in the body. It is now a pipeable declared at the function boundary, + `Effect.fn(name)(body, Db.requireTransaction)`, mirroring `Db.transaction()`. Same + compile-time check; `Db.Transaction` stays for reading depth and settings. +- **Namespace import, not static members.** The subpath exports flat names, used as + `import * as Db from "@maple-dev/effect-orm/database"`: `Db.Database` (the service), + `Db.run`, `Db.transaction`, `Db.Transaction`. This matches `Migrate.run` / + `Migrate.MigrationDriver`. The examples above that say `Database.run` read as `Db.run`. +- **`query` was added** beside `run` and `execute`, for raw statements whose rows are wanted + undecoded (advisory locks, `RETURNING` before the write builders exist). +- **`TransactionClosed` and dialect mismatch are defects**, not typed errors. Both are + programming bugs (an unjoined fork, the wrong `compile`), and as typed errors they would sit + in the error channel of every `run`. This follows the rule `compile` already uses: expected + failures are typed, bugs die. `TransactionClosed` is therefore not in `TransactionError`. A + dialect mismatch dies with a `DatabaseError` whose `reason` is `DialectMismatch`. +- **`Dialect.transactions` is optional**, absent meaning none (`noTransactions`), so dialects + defined outside the package keep compiling. `clickhouseDialect` sets it explicitly. +- **`CompiledQuery.dialect` is optional**: set by every builder compile, and an option on + `rawCompiledQuery`. `run` checks it only when present. +- **SQLSTATE from the cause chain.** `@effect/sql-pglite` does not classify 40001 or 40P01 + into `SerializationError` or `DeadlockError` the way `@effect/sql-pg` does. So + `DatabaseError` and `TransactionCommitFailed` carry `sqlState` read from the cause chain, + and `isContention` accepts either signal. +- **Retry options** are `{ times, schedule }` (defaults 3 and exponential from 50 ms), fed to + `Effect.retry` with `while: isContention`. +- **PGlite 0.5 uses the host time zone** for its sessions, where 0.3 used UTC. Every PGlite in + the tests is created with `postgresqlconf: "timezone = 'UTC'"`. +- **Not yet done from phase 2:** the over-the-wire `@effect/sql-pg` suite (two connections, + real serialization failures), and filing the upstream Effect issue for orDie'd COMMIT and + ROLLBACK. The PGlite suite simulates contention with `RAISE ... USING ERRCODE`. diff --git a/docs/README.md b/docs/README.md index 669926c..0e2dba6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,7 @@ Roughly in reading order. | [Extending the DSL](./extending.md) | `defineFn`, raw escape hatches, handwritten SQL | | [Postgres](./postgres.md) | The Postgres dialect, its column types and functions | | [Schema and migrations](./migrations.md) | `defineTable`, `materializedView`, `effect-orm generate`, applying migrations | +| [Statements and transactions](./database.md) | `Database` over your `SqlClient`: `run`, `execute`, `transaction`, retry | ## Reference @@ -77,6 +78,7 @@ Roughly in reading order. | `@maple-dev/effect-orm/schema` | `defineTable`, `materializedView`, DDL rendering, snapshots, and the schema diff. Pure | | `@maple-dev/effect-orm/kit` | `generate` and `check` over a migrations folder, `defineConfig`, and `runCli` for the bundled `effect-orm` command. Node or Bun | | `@maple-dev/effect-orm/migrate` | `run`, `status`, `verify`, and `MigrationDriver`: applies migrations through a driver you provide | +| `@maple-dev/effect-orm/database` | `Database` over your `SqlClient`: `run` compiled queries, `execute` statements, `transaction` with settings and contention retry | The root barrel is curated, not exhaustive — see [the reference](./reference.md#whats-only-on-a-subpath) for what lives only on a subpath. diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..d21a857 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,251 @@ +# Running statements and transactions + +`@maple-dev/effect-orm/database` runs compiled queries and other statements through an Effect +`SqlClient` you already have, and wraps them in transactions. The package still opens no +connections: a `Database` is a small object over your client. + +```sh +npm install @effect/sql-pg@4.0.0 # or @effect/sql-pglite, @effect/sql-clickhouse +``` + +Transactions are Effect's own `SqlClient.withTransaction`, with four additions: + +- **Settings**: isolation level, access mode, deferrable. +- **Typed COMMIT and ROLLBACK failures**: Effect 4.0.0 turns them into defects. +- **A guard** against statements that outlive their transaction. +- **Contention retry** for SQLSTATE 40001 and 40P01. + +Postgres supports all of it. ClickHouse has no transactions here; see [ClickHouse](#clickhouse). + +## A complete example + +This runs on PGlite, Postgres compiled to WASM, so it needs no server. Swap +`PgliteClient.layer` for `PgClient.layer` from `@effect/sql-pg` and nothing else changes. + +```ts title="database-transaction.ts" +import { PgliteClient } from "@effect/sql-pglite" +import { Effect, Layer, Schema } from "effect" +import * as CH from "@maple-dev/effect-orm" +import * as Db from "@maple-dev/effect-orm/database" +import * as PG from "@maple-dev/effect-orm/postgres" + +const Accounts = CH.table("accounts", { id: PG.int4, balance: PG.int8 }) + +class InsufficientFunds extends Schema.TaggedError()("InsufficientFunds", { + account: Schema.Number, +}) {} + +const balanceOf = (id: number) => + Db.run( + CH.from(Accounts) + .select("balance") + .where(($) => [$.id.eq(id)]), + ).pipe(Effect.map((rows) => rows[0]?.balance ?? 0)) + +// Must run inside a transaction: it reads, then writes. +const withdraw = Effect.fn("withdraw")(function* (id: number, amount: number) { + if ((yield* balanceOf(id)) < amount) return yield* new InsufficientFunds({ account: id }) + yield* Db.execute(Db.sql`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${id}`) +}, Db.requireTransaction) + +// Opens a transaction (or a savepoint, inside one). +export const transfer = Effect.fn("transfer")( + function* (from: number, to: number, amount: number) { + yield* withdraw(from, amount) + yield* Db.execute(Db.sql`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${to}`) + }, + Db.transaction({ isolationLevel: "serializable", retry: "contention" }), +) + +const DatabaseLive = Db.layerSqlClient({ dialect: PG.postgresDialect }).pipe( + Layer.provideMerge(PgliteClient.layer({ postgresqlconf: "timezone = 'UTC'" })), +) + +export const balances = await Effect.runPromise( + Effect.gen(function* () { + yield* Db.execute(Db.sql`CREATE TABLE accounts (id int4 PRIMARY KEY, balance int8 NOT NULL)`) + yield* Db.execute(Db.sql`INSERT INTO accounts VALUES (1, 100), (2, 0)`) + yield* transfer(1, 2, 30) + // Fails: the withdrawal rolls back with the transaction. + const refused = yield* Effect.flip(transfer(1, 2, 500)) + return { from: yield* balanceOf(1), to: yield* balanceOf(2), refused: refused._tag } + }).pipe(Effect.provide(DatabaseLive)), +) +// { from: 70, to: 30, refused: "InsufficientFunds" } +``` + +Calling `withdraw(1, 30)` outside `transfer` does not compile: `requireTransaction` puts +`Transaction` in its requirements, and only `transaction` removes it. + +## Building a `Database` + +| Export | What it is | +| --- | --- | +| `fromSqlClient(sql, options)` | A `DatabaseApi` over a client. `options.dialect` is required | +| `layerSqlClient(options)` | A `Database` layer over the `SqlClient` in context | +| `Database` | The service | +| `run(query, params?)` | Compile a query for the database's dialect, run it, decode its rows | +| `sql\`...\`` | A statement with every `${value}` bound; `sql.identifier(name)` for a table or column name | +| `query(statement, schema?)` | Run a statement and return its rows, decoded when a schema is given | +| `execute(statement)` | Run a statement and discard its rows | +| `transaction(options?)` | Run an effect in a transaction; data-first or pipeable | +| `requireTransaction` | Mark an effect as correct only inside a transaction | +| `retryContention(options?)` | Re-run on serialization failure or deadlock | +| `Transaction` | The open transaction (`depth`, `isolationLevel`, `accessMode`) | +| `isContention(error)` | Whether an error is a serialization failure or deadlock | + +`run`, `query`, `execute`, `transaction` and `retryContention` are also methods on +`DatabaseApi`. + +### Queries and statements + +`run` takes the query you built, a `unionAll`, or a query compiled elsewhere. It compiles with +the database's dialect, so you never pick a `compile`; `params` fills the query's `param.*` +markers, and a missing one fails with `QueryBuilderError`. A query compiled elsewhere must +have been compiled for the same dialect, or `run` dies: the root `compile` is ClickHouse's. + +`sql` writes the statements the builder does not have yet (INSERT, UPDATE, DDL, advisory +locks). Each `${value}` is bound, as `$1, $2, ...` on Postgres and as an escaped literal on +ClickHouse, so nothing in a value becomes SQL. A `sql` inside another is spliced, so +statements compose. Names go through `sql.identifier`, which accepts only plain identifiers +(dotted for `schema.table`) and quotes them: + +```ts +const where = Db.sql`org_id = ${orgId} AND revoked = false` +yield* Db.execute(Db.sql`UPDATE ${Db.sql.identifier(table)} SET revoked = true WHERE ${where}`) + +const Claimed = Schema.Struct({ org_id: Schema.String, family: Schema.String }) +const claimed = yield* Db.query( + Db.sql`UPDATE api_keys SET revoked = true WHERE id = ${id} AND revoked = false RETURNING org_id, family`, + Claimed, +) // ReadonlyArray<{ org_id: string; family: string }> +``` + +`query` and `execute` also take a plain `{ sql, parameters }` for SQL you have as text. + +`FromSqlClientOptions`: + +| Option | Meaning | +| --- | --- | +| `dialect` | `postgresDialect` or `clickhouseDialect` | +| `command` | Wraps `execute`. Pass ClickHouse's `asCommand`: DDL has no JSON result | +| `observe` | Called with every statement before it runs, including the `SET TRANSACTION` a transaction's settings become | + +`run` refuses a query compiled for another dialect (`CompiledQuery.dialect`), as a defect: a +query built with the root `compile`, which is ClickHouse's, can run on Postgres with the wrong +quoting and inlined params. Rows come back without the client's name transforms, because the +decoder reads the aliases the compiler wrote. + +Build the `Database` wherever you build the client. If each request has its own pool, build a +`Database` per request from it: nothing request-scoped is stored in the `Database`, and the +open transaction lives in fiber context. + +## Transactions + +```ts +Effect.fn("op")(function* () { ... }, Db.transaction()) // as an Effect.fn pipe argument +body.pipe(Db.transaction({ isolationLevel: "serializable" })) // pipeable +Db.transaction(body) // data-first +``` + +- **Pinning.** Every statement from the same client inside `body` runs on the transaction's + connection, found in fiber context. There is no `tx` handle: `run` and `execute` work the + same inside and outside a transaction. That includes statements made with the client + directly (`sql\`...\``) and with other libraries over the same client. +- **Rollback.** Any failure, defect or interruption in `body` rolls back. Your errors come + out unchanged; roll back by failing with one. +- **Nesting.** A `transaction` inside another becomes a savepoint. A failed inner one rolls + back to its savepoint, and the outer one continues if you catch the error. A nested + `sql.withTransaction` and a nested `Db.transaction` share one counter. +- **Interruption.** BEGIN cannot be interrupted halfway; the body can, and interruption rolls + back before the connection goes back to the pool. + +### Requiring a transaction + +Some helpers are only correct inside a transaction: a read followed by a write, two writes +that must land together. Mark them with `requireTransaction`, the same way `transaction` +marks the operations that open one: + +```ts +const revokeFamily = Effect.fn("revokeFamily")(function* (family: string) { + yield* Db.execute(Db.sql`UPDATE api_keys SET revoked = true WHERE family = ${family}`) + yield* Db.execute(Db.sql`UPDATE refresh_tokens SET revoked = true WHERE family = ${family}`) +}, Db.requireTransaction) + +revokeFamily("f1") // Effect +Db.transaction(revokeFamily("f1")) // Effect +``` + +`requireTransaction` adds `Transaction` to the requirements and `transaction` removes it, so a +call outside a transaction is a compile error, not a bug found in production. This is how +Effect's own `Effect.tx` treats `Effect.Transaction`. A helper that works either way, like +most reads, needs no marker. Inside a transaction, `yield* Db.Transaction` gives its `depth` +and settings. + +### Settings + +| Option | Postgres | +| --- | --- | +| `isolationLevel` | `"read committed"`, `"repeatable read"` or `"serializable"` | +| `accessMode` | `"read write"` or `"read only"` | +| `deferrable` | With `serializable` and `read only`, wait for a safe snapshot | +| `retry` | `"contention"`, or `{ times, schedule }` | + +Settings become one `SET TRANSACTION ...` statement, run first. A nested transaction cannot +set any of them, or `retry`, and fails with `TransactionOptionsRejected`: Postgres fixes the +isolation level at the first statement, and a retry has to start the outermost transaction +again. `read uncommitted` is not offered because Postgres runs it as `read committed`. + +### Errors + +| Error | When | +| --- | --- | +| `DatabaseError` | A statement failed. `reason` is the driver's classification (`UniqueViolation`, `SerializationError`, ...) and `sqlState` the server's code | +| `TransactionCommitFailed` | COMMIT failed: a deferred constraint, a serialization failure at commit, a dropped connection. Carries `reason` and `sqlState` | +| `TransactionRollbackFailed` | ROLLBACK failed after the body failed. `bodyCause` is the body's failure, `cause` the rollback's | +| `TransactionOptionsRejected` | A setting the dialect does not support, or settings on a nested transaction | +| `TransactionUnsupported` | The dialect has no transactions (ClickHouse) | + +Effect 4.0.0 turns a failed COMMIT or ROLLBACK into a defect and, when ROLLBACK fails, drops +the body's own failure. `transaction` turns both back into the typed errors above. Defects +from `body` stay defects. + +`TransactionClosed` is a defect, not an error to handle. A fiber forked inside a transaction +inherits its connection. If it runs a statement after the transaction ended, that statement +would run on a connection already back in the pool, so `run`, `query` and `execute` die with +`TransactionClosed` instead. Join what you fork before the body returns. + +### Contention retry + +`retryContention(effect)` re-runs `effect` when it fails with `DatabaseError` or +`TransactionCommitFailed` for a serialization failure or deadlock (SQLSTATE 40001 or 40P01). +By default it retries 3 times with exponential backoff from 50 ms. It never retries your own +errors. Use it around a transaction, or around a single statement outside one. Inside an open +transaction it fails with `TransactionOptionsRejected`, because the database has already +aborted the whole transaction. `transaction(body, { retry: "contention" })` is shorthand for +`retryContention(transaction(body))`, so each attempt starts with a fresh BEGIN. + +## ClickHouse + +`clickhouseDialect` declares no transactions, so `transaction` fails with +`TransactionUnsupported` before anything is sent. `run`, `query` and `execute` work as on +Postgres. Pass `command: client.asCommand` so `execute` can run DDL. + +This is deliberate. A default ClickHouse server refuses `BEGIN TRANSACTION` +(`NOT_IMPLEMENTED`). Experimental transactions need a server setting and Keeper, and even then: + +- **MergeTree only**: no Memory tables, no DDL, no savepoints, no nesting. +- **A session is required.** Over HTTP, a `BEGIN` without a `session_id` succeeds and is + forgotten. The INSERTs after it are saved immediately, and the COMMIT fails with + `There is no current transaction`. +- **Readers outside a transaction can see uncommitted rows.** + +Effect's ClickHouse client has no session, so its own `withTransaction` cannot be atomic. It +fails at BEGIN today: through the query path with a syntax error, and through `asCommand` with +`NOT_IMPLEMENTED`. The full measurements are in +[`design/transactions.md`](../design/transactions.md). + +## Writes + +The builder compiles SELECTs only, so far. Write INSERT, UPDATE and DELETE with `sql`, as +above, and read `RETURNING` with `query` and a schema. diff --git a/docs/reference.md b/docs/reference.md index 840af5e..1e145d7 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -37,6 +37,8 @@ The root barrel is curated. These are exported by the package but not from it: | `makeHttpClient`, `makeHttpTransport`, `httpConfigFromEnv` | `/benchmark/http` | | `runCli` | `/benchmark/cli` | | `postgresDialect`, Postgres column types and functions, Postgres-default `compile` | `/postgres` | +| `Database`, `run`, `sql`, `query`, `execute`, `transaction`, `requireTransaction`, `retryContention`, `Transaction` | `/database` | +| `DatabaseError`, `TransactionCommitFailed`, `TransactionRollbackFailed` and the other transaction errors | `/database` | Every column-type constructor and every expression helper is on the root as well as on its subpath. See [Running a query](./running-queries.md) for what the `/sql` statement helpers are @@ -88,12 +90,13 @@ Note `/sql` exports a `compile` (fragment → string) distinct from the root `co | `compileUnsafe` | The same, returning `CompiledQuery` and throwing instead | | `compileUnion` | `(union, params, options?) => Effect, QueryBuilderError>` | | `compileUnionUnsafe` | The same, throwing instead | -| `rawCompiledQuery` | `({ sql, tenantScope, reason, justification, rowSchema?, route? }) => CompiledQuery` | +| `rawCompiledQuery` | `({ sql, tenantScope, reason, justification, rowSchema?, route?, dialect? }) => CompiledQuery` | | `clickhouseDialect` | The default `Dialect`: params written into the SQL as ClickHouse literals | `Dialect`, `DialectClauses` and `ParamStyle` describe a database: how identifiers and literals are written, how params reach the server, and which clauses exist. Pass one as -`options.dialect`. See [Params and compilation](./params-and-compilation.md#dialects) and +`options.dialect`. `DialectTransactions` (with `IsolationLevel` and `TransactionSettings`) says +which transactions the database supports; see [Database](./database.md). See [Params and compilation](./params-and-compilation.md#dialects) and [Postgres](./postgres.md). ### Params @@ -276,7 +279,7 @@ Types: `WindowSpec`, `CompiledWindowSpec`, `WindowFrameBound`, `WindowRowsFrame` **Everything else** — `Table`, `TableOptions`, `Expr`, `ColumnRef`, `Condition`, `Comparable` (what a value of a type may be compared against), `MapValueOf`, `Subquery`, `ParamMarker`, `ParamKind`, `CHQuery`, `CHUnionQuery`, `ColumnAccessor`, `JoinedColumnAccessor`, -`JoinOnCallback`, `CompiledQuery`, `CompiledQueryInput`, `CompiledQueryRowSchema`, `RowSchemaMismatch`, `TenantScope`, `Dialect`, `DialectClauses`, `ParamStyle`, `FnResult`, +`JoinOnCallback`, `CompiledQuery`, `CompiledQueryInput`, `CompiledQueryRowSchema`, `RowSchemaMismatch`, `TenantScope`, `Dialect`, `DialectClauses`, `DialectTransactions`, `IsolationLevel`, `TransactionSettings`, `ParamStyle`, `FnResult`, `WindowFunnelMode`, `WindowSpec`, `WindowRowsFrame`, `WindowFrameBound`, `WindowOrderDirection`, `CompiledWindowSpec`. diff --git a/docs/running-queries.md b/docs/running-queries.md index 0747123..ccb630e 100644 --- a/docs/running-queries.md +++ b/docs/running-queries.md @@ -66,6 +66,10 @@ runtimes, use a runtime-compatible adapter such as `@clickhouse/client-web` with integration. Keep database credentials on your server. See the [Effect ClickHouse driver source](https://github.com/Effect-TS/effect/blob/main/packages/sql/clickhouse/src/ClickhouseClient.ts). +For a client that implements Effect's `SqlClient`, the opt-in +[`@maple-dev/effect-orm/database`](./database.md) entry point does this loop for you +(`Db.run(compiled)`), and adds transactions on Postgres. + ## Formats and numeric precision Leave `.format()` off the builder query. Effect's ClickHouse client requests `FORMAT JSON` diff --git a/package.json b/package.json index 057bede..85f73ff 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,10 @@ "types": "./dist/migrate.d.mts", "import": "./dist/migrate.mjs" }, + "./database": { + "types": "./dist/database.d.mts", + "import": "./dist/database.mjs" + }, "./kit": { "types": "./dist/kit.d.mts", "import": "./dist/kit.mjs" @@ -95,8 +99,9 @@ "@effect/language-service": "^0.87.3", "@effect/platform-bun": "4.0.0", "@effect/sql-clickhouse": "4.0.0", + "@effect/sql-pglite": "4.0.0", "@effect/vitest": "4.0.0", - "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite": "0.5.8", "@types/node": "^22.10.2", "effect": "4.0.0", "expect-type": "^1.3.0", diff --git a/scripts/check-doc-examples.mjs b/scripts/check-doc-examples.mjs index 6873608..5143aed 100644 --- a/scripts/check-doc-examples.mjs +++ b/scripts/check-doc-examples.mjs @@ -108,6 +108,8 @@ assert.deepEqual(postgres.rows, [ { route: "/checkout", count: 2, slow: 1, p50: 510 }, { route: "/search", count: 1, slow: 0, p50: 40 }, ]) +const database = await import("./database-transaction") +assert.deepEqual(database.balances, { from: 70, to: 30, refused: "InsufficientFunds" }) console.log("Markdown example behavior checks passed") `, ) diff --git a/src/ch/compile.ts b/src/ch/compile.ts index 973440f..4eb2909 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -186,6 +186,15 @@ interface CompiledQueryBase { * human reviewer can see. */ readonly rawSql?: { readonly reason: string; readonly justification: string } + /** + * The `name` of the dialect the query was compiled for (`clickhouse`, + * `postgres`). Absent for handwritten SQL that did not say. + * + * Lets an executor refuse a query compiled for another database, the usual + * cause being the root `compile` (ClickHouse) where the Postgres one was + * meant: the SQL may even run, with ClickHouse quoting and inlined params. + */ + readonly dialect?: string /** Runtime decode of raw query results. Queries built from handwritten SQL * should provide a row schema so schema drift is caught before consumers * read fields from `Record`. Without a schema this is an @@ -325,6 +334,7 @@ const makeCompiledQuery = ( untypedColumns: ReadonlyArray = [], rawSql?: { readonly reason: string; readonly justification: string }, rowSchemaMismatch?: RowSchemaMismatch, + dialect?: string, ): CompiledQuery => { let cachedDecodeRow: ((row: unknown) => Effect.Effect) | undefined let decoderBuilt = false @@ -397,6 +407,7 @@ const makeCompiledQuery = ( untypedColumns: rowSchemaSource === "none" ? untypedColumns : [], rowSchemaMismatch, ...(rawSql !== undefined ? { rawSql } : undefined), + ...(dialect !== undefined ? { dialect } : undefined), ...(!(route === undefined) ? { route } : undefined), decodeRows, encodeRows, @@ -444,6 +455,8 @@ export const rawCompiledQuery = < readonly justification: string readonly rowSchema?: CompiledQueryRowSchema readonly route?: Route + /** The `name` of the dialect the SQL is written for, so an executor can check it. */ + readonly dialect?: string }): CompiledQuery => makeCompiledQuery( args.sql, @@ -454,6 +467,8 @@ export const rawCompiledQuery = < args.route, [], { reason: args.reason, justification: args.justification }, + undefined, + args.dialect, ) /** @@ -810,6 +825,7 @@ function compileInner< options?.rowSchema === undefined ? undefined : compareRowSchemas(options.rowSchema, derivedSchema), + currentDialect().name, ), tenantScope === "single-tenant" ? scope.bound : undefined, ) @@ -1141,6 +1157,7 @@ function compileUnionInner, Params extends Re options?.rowSchema === undefined ? undefined : compareRowSchemas(options.rowSchema, derivedSchema), + currentDialect().name, ), tenantScope === "single-tenant" ? [...bounds][0] : undefined, ) diff --git a/src/ch/dialect.ts b/src/ch/dialect.ts index 9d401fb..60160f8 100644 --- a/src/ch/dialect.ts +++ b/src/ch/dialect.ts @@ -55,6 +55,51 @@ export interface DialectClauses { readonly parenthesizeUnionBranches: boolean } +/** A transaction isolation level. `read uncommitted` is left out: Postgres runs it as `read committed`. */ +export type IsolationLevel = "read committed" | "repeatable read" | "serializable" + +/** What a transaction starts with. Every field is optional; an empty value changes nothing. */ +export interface TransactionSettings { + readonly isolationLevel?: IsolationLevel | undefined + readonly accessMode?: "read write" | "read only" | undefined + /** Postgres: wait for a safe snapshot. Only meaningful with `serializable` and `read only`. */ + readonly deferrable?: boolean | undefined +} + +/** + * What transactions a dialect supports. Read by `@maple-dev/effect-orm/database` + * before any statement is sent, so an unsupported request fails instead of + * pretending. See `design/transactions.md`. + */ +export interface DialectTransactions { + /** + * `none`: a transaction fails with `TransactionUnsupported`. ClickHouse is + * `none`: its transactions are experimental, need a server flag and an HTTP + * session, and a BEGIN without a session is silently a no-op. + */ + readonly support: "none" | "full" + /** Whether a nested transaction becomes a savepoint. Without it, nesting fails. */ + readonly savepoints: boolean + readonly isolationLevels: ReadonlyArray + readonly accessModes: boolean + readonly deferrable: boolean + /** + * The statement that applies `settings`, run as the first statement of a + * transaction, or `undefined` when there is nothing to apply. + */ + readonly setTransaction: (settings: TransactionSettings) => string | undefined +} + +/** No transactions. What a dialect without a `transactions` entry gets. */ +export const noTransactions: DialectTransactions = { + support: "none", + savepoints: false, + isolationLevels: [], + accessModes: false, + deferrable: false, + setTransaction: () => undefined, +} + /** * A database the builder writes SQL for. * @@ -75,6 +120,8 @@ export interface Dialect extends SqlSyntax { * instant for the other. Kinds not listed use the ClickHouse codec. */ readonly paramCodecs?: Readonly>> + /** Transaction support. Absent means none. */ + readonly transactions?: DialectTransactions } /** ClickHouse, with params written into the SQL as literals. The default. */ @@ -86,6 +133,7 @@ export const clickhouseDialect: Dialect = { dateTimeLiteral: (value) => quoteClickHouseString(chDateTimeLiteral(value)), params: { _tag: "inline" }, clauses: { format: true, derivedTableAlias: false, groupByAlias: true, parenthesizeUnionBranches: false }, + transactions: noTransactions, } // The dialect of the enclosing compile, beside the syntax installed for the diff --git a/src/ch/index.ts b/src/ch/index.ts index 7875d01..c669cb6 100644 --- a/src/ch/index.ts +++ b/src/ch/index.ts @@ -258,7 +258,15 @@ export { } from "./compile" // Dialects: how a compiled query's params reach the server. -export { clickhouseDialect, type Dialect, type DialectClauses, type ParamStyle } from "./dialect" +export { + clickhouseDialect, + type Dialect, + type DialectClauses, + type DialectTransactions, + type IsolationLevel, + type ParamStyle, + type TransactionSettings, +} from "./dialect" // Failures vs defects — the rule the two classes encode is on `QueryBuilderError`. export { QueryBuilderError, QueryBuilderDefect } from "./errors" diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..9d223eb --- /dev/null +++ b/src/database.ts @@ -0,0 +1,41 @@ +// @maple-dev/effect-orm/database +// +// Runs compiled statements through a `SqlClient` you provide, and wraps them in +// transactions: Effect's own `withTransaction` plus isolation settings, typed +// COMMIT and ROLLBACK failures, contention retry, and `requireTransaction` for +// helpers that must be atomic. See docs/database.md. + +export { + Database, + Transaction, + execute, + fromSqlClient, + isContention, + layerSqlClient, + query, + requireTransaction, + retryContention, + run, + transaction, + type DatabaseApi, + type RowOf, + type RowSchema, + type Runnable, + type StatementInput, + type FromSqlClientOptions, + type RetryOptions, + type Statement, + type TransactionInfo, + type TransactionOptions, +} from "./database/database" +export { + DatabaseError, + TransactionClosed, + TransactionCommitFailed, + TransactionOptionsRejected, + TransactionRollbackFailed, + TransactionUnsupported, + type TransactionError, +} from "./database/errors" +export { sql, type SqlIdentifier, type SqlTemplate } from "./database/sql" +export type { DialectTransactions, IsolationLevel, TransactionSettings } from "./ch/dialect" diff --git a/src/database/database.test-d.ts b/src/database/database.test-d.ts new file mode 100644 index 0000000..fa16755 --- /dev/null +++ b/src/database/database.test-d.ts @@ -0,0 +1,63 @@ +// Type-level tests: rows from `run`, `requireTransaction`, and what `transaction` removes. + +import { Effect, Schema } from "effect" +import { expectTypeOf } from "expect-type" +import * as CH from "../index" +import * as PG from "../postgres" +import * as Db from "../database" + +class Domain { + readonly _tag = "Domain" +} + +const Items = CH.table("t", { id: PG.int4, note: PG.text }) +type Row = { readonly id: number; readonly note: string } +type RunError = Db.DatabaseError | CH.QueryBuilderError | CH.CompiledQueryDecodeError + +// `run` takes the query itself and infers its rows. +expectTypeOf(Db.run(CH.from(Items).select("id", "note"))).toEqualTypeOf, RunError, Db.Database>>() +expectTypeOf(Db.run(CH.unionAll(CH.from(Items).select("id", "note"), CH.from(Items).select("id", "note")))).toEqualTypeOf< + Effect.Effect, RunError, Db.Database> +>() +// ...or a query compiled elsewhere. +expectTypeOf(Db.run(PG.compileUnsafe(CH.from(Items).select("id", "note"), {}))).toEqualTypeOf< + Effect.Effect, RunError, Db.Database> +>() + +// `query` is untyped without a schema and typed with one. +expectTypeOf(Db.query(Db.sql`SELECT 1`)).toEqualTypeOf< + Effect.Effect>, Db.DatabaseError, Db.Database> +>() +expectTypeOf(Db.query(Db.sql`SELECT 1 AS n`, Schema.Struct({ n: Schema.Number }))).toEqualTypeOf< + Effect.Effect, Db.DatabaseError | CH.CompiledQueryDecodeError, Db.Database> +>() + +// `requireTransaction` adds the requirement; `transaction` removes it. +const revoke = Effect.fn("revoke")(function* (family: string) { + yield* Db.execute(Db.sql`UPDATE t SET note = '' WHERE note = ${family}`) + return 1 +}, Db.requireTransaction) +expectTypeOf(revoke).returns.toEqualTypeOf>() + +type Wrapped = Effect.Effect +expectTypeOf(Db.transaction(revoke("f"))).toEqualTypeOf() +expectTypeOf(revoke("f").pipe(Db.transaction({ isolationLevel: "serializable" }))).toEqualTypeOf() + +const op = Effect.fn("op")(function* () { + return yield* revoke("f") +}, Db.transaction()) +expectTypeOf(op).returns.toEqualTypeOf() + +// Domain errors stay in the error channel beside the transaction's. +expectTypeOf(Db.transaction(Effect.fail(new Domain()))).toEqualTypeOf< + Effect.Effect +>() + +// Without the wrapper, the requirement cannot be satisfied by a Database alone. +// @ts-expect-error Transaction is still required +const unwrapped: Effect.Effect = revoke("f") +void unwrapped + +// Settings are typed: an unknown isolation level does not compile. +// @ts-expect-error not an isolation level +void Db.transaction(Effect.void, { isolationLevel: "read uncommitted" }) diff --git a/src/database/database.test.ts b/src/database/database.test.ts new file mode 100644 index 0000000..b3d8d3b --- /dev/null +++ b/src/database/database.test.ts @@ -0,0 +1,392 @@ +// `Database` against a real Effect SqlClient: `@effect/sql-pglite` (Postgres 17 +// in WASM). Transactions here are Effect's own `withTransaction`, so these +// tests check the layer on top of it, not a fake. PGlite has one connection, so +// isolation between two transactions is out of reach here. + +import { PgliteClient } from "@effect/sql-pglite" +import { assert, describe, expect, it, layer } from "@effect/vitest" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Ref, Schedule, Schema } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import * as CH from "../index" +import * as PG from "../postgres" +import { clickhouseDialect } from "../ch/dialect" +import { postgresDialect } from "../pg/dialect" +import * as Db from "../database" +import { renderTemplate } from "./sql" + +const statements: Array = [] + +const Live = Layer.effect( + Db.Database, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return Db.fromSqlClient(sql, { + dialect: postgresDialect, + observe: (statement) => Effect.sync(() => void statements.push(statement.sql)), + }) + }), +).pipe(Layer.provideMerge(PgliteClient.layer({ postgresqlconf: "timezone = 'UTC'" }))) + +let tables = 0 +/** A fresh table per test: the client is shared across the file. */ +const freshTable = Effect.gen(function* () { + const name = `t_${++tables}` + yield* Db.execute(Db.sql`CREATE TABLE ${Db.sql.identifier(name)} (id int4 PRIMARY KEY, note text)`) + return name +}) +const insert = (table: string, id: number, note = "") => + Db.execute(Db.sql`INSERT INTO ${Db.sql.identifier(table)} VALUES (${id}, ${note})`) +const ids = (table: string) => + Db.run(CH.from(CH.table(table, { id: PG.int4 })).select("id").orderBy(["id", "asc"])).pipe( + Effect.map((rows) => rows.map((row) => row.id)), + ) +// DO blocks take no bound values, so the code is written in. Test-only. +const raiseSqlState = (code: "40001" | "40P01") => + Db.execute({ sql: `DO $$ BEGIN RAISE EXCEPTION 'forced' USING ERRCODE = '${code}'; END $$` }) + +class Domain extends Error { + readonly _tag = "Domain" +} + +layer(Live, { excludeTestServices: true })("Database on PGlite", (it) => { + it.effect("run compiles a query for the database's dialect, inside and outside a transaction", () => + Effect.gen(function* () { + const table = yield* freshTable + yield* insert(table, 1) + expect(yield* ids(table)).toEqual([1]) + expect(yield* Db.transaction(ids(table))).toEqual([1]) + }), + ) + + it.effect("run fills params, and a missing one is a QueryBuilderError", () => + Effect.gen(function* () { + const table = yield* freshTable + yield* Effect.all([insert(table, 1, "a"), insert(table, 2, "b")]) + const Items = CH.table(table, { id: PG.int4, note: PG.text }) + const byNote = CH.from(Items) + .select("id") + .where(($) => [$.note.eq(CH.param.string("note"))]) + expect(yield* Db.run(byNote, { note: "b" })).toEqual([{ id: 2 }]) + const error = yield* Effect.flip(Db.run(byNote)) + expect(error).toBeInstanceOf(CH.QueryBuilderError) + }), + ) + + it.effect("sql binds every value: nothing in a value becomes SQL", () => + Effect.gen(function* () { + const table = yield* freshTable + const hostile = "x'); DROP TABLE t_1; --" + yield* insert(table, 1, hostile) + const where = Db.sql`note = ${hostile}` + const rows = yield* Db.query(Db.sql`SELECT id, note FROM ${Db.sql.identifier(table)} WHERE ${where}`) + expect(rows).toEqual([{ id: 1, note: hostile }]) + }), + ) + + it.effect("sql.identifier accepts only plain names", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Db.execute(Db.sql`SELECT * FROM ${Db.sql.identifier("t; DROP TABLE x")}`)) + expect(error.reason).toBe("InvalidLiteral") + }), + ) + + it.effect("query decodes rows through a schema", () => + Effect.gen(function* () { + const table = yield* freshTable + yield* insert(table, 1, "a") + const Row = Schema.Struct({ id: Schema.Number, note: Schema.String }) + const rows = yield* Db.query(Db.sql`UPDATE ${Db.sql.identifier(table)} SET note = 'b' RETURNING id, note`, Row) + expect(rows).toEqual([{ id: 1, note: "b" }]) + const error = yield* Effect.flip(Db.query(Db.sql`SELECT 'x' AS id`, Row)) + expect(error).toBeInstanceOf(CH.CompiledQueryDecodeError) + }), + ) + + it.effect("refuses a query compiled for another dialect", () => + Effect.gen(function* () { + // The root compile is ClickHouse's. + const compiled = yield* CH.compile(CH.from(CH.table("x", { id: PG.int4 })).select("id"), {}) + const exit = yield* Effect.exit(Db.run(compiled)) + assert(Exit.isFailure(exit) && Cause.hasDies(exit.cause)) + const defect = Cause.squash(exit.cause) + assert(defect instanceof Db.DatabaseError) + expect(defect.reason).toBe("DialectMismatch") + }), + ) + + it.effect("commits on success", () => + Effect.gen(function* () { + const table = yield* freshTable + yield* Db.transaction(Effect.all([insert(table, 1), insert(table, 2)])) + expect(yield* ids(table)).toEqual([1, 2]) + }), + ) + + it.effect("rolls back a typed failure and passes it through unchanged", () => + Effect.gen(function* () { + const table = yield* freshTable + const domain = new Domain("claim lost") + const error = yield* Effect.flip(Db.transaction(insert(table, 1).pipe(Effect.andThen(Effect.fail(domain))))) + expect(error).toBe(domain) + expect(yield* ids(table)).toEqual([]) + }), + ) + + it.effect("rolls back a defect and keeps it a defect", () => + Effect.gen(function* () { + const table = yield* freshTable + const bug = new Error("bug") + const exit = yield* Effect.exit(Db.transaction(insert(table, 1).pipe(Effect.andThen(Effect.die(bug))))) + assert(Exit.isFailure(exit)) + expect(Cause.squash(exit.cause)).toBe(bug) + expect(yield* ids(table)).toEqual([]) + }), + ) + + it.effect("a SqlError defect from the body stays the body's, not a rollback failure", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const table = yield* freshTable + const exit = yield* Effect.exit( + Db.transaction(insert(table, 1).pipe(Effect.andThen(Effect.orDie(sql.unsafe("SELECT * FROM missing_table"))))), + ) + assert(Exit.isFailure(exit) && Cause.hasDies(exit.cause)) + expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "SqlError" }) + expect(yield* ids(table)).toEqual([]) + }), + ) + + it.effect("rolls back on interruption and frees the connection", () => + Effect.gen(function* () { + const table = yield* freshTable + const inserted = yield* Deferred.make() + const fiber = yield* Db.transaction( + insert(table, 1).pipe(Effect.andThen(Deferred.succeed(inserted, undefined)), Effect.andThen(Effect.never)), + ).pipe(Effect.forkChild) + yield* Deferred.await(inserted) + yield* Fiber.interrupt(fiber) + expect(yield* ids(table)).toEqual([]) + yield* Db.transaction(insert(table, 2)) + expect(yield* ids(table)).toEqual([2]) + }), + ) + + it.effect("nests as savepoints: a caught inner failure keeps the outer writes", () => + Effect.gen(function* () { + const table = yield* freshTable + const depths = yield* Db.transaction( + Effect.gen(function* () { + const outer = yield* Db.Transaction + yield* insert(table, 1) + yield* Db.transaction(insert(table, 2).pipe(Effect.andThen(Effect.fail(new Domain("inner"))))).pipe( + Effect.catchIf( + (error) => error instanceof Domain, + () => Effect.void, + ), + ) + const middle = yield* Db.transaction( + Effect.gen(function* () { + yield* insert(table, 3) + return yield* Db.transaction(Effect.map(Effect.service(Db.Transaction), (info) => info.depth)) + }), + ) + return [outer.depth, middle] + }), + ) + expect(depths).toEqual([0, 2]) + expect(yield* ids(table)).toEqual([1, 3]) + }), + ) + + it.effect("refuses settings or retry on a nested transaction", () => + Effect.gen(function* () { + const table = yield* freshTable + const inner = yield* Db.transaction( + insert(table, 1).pipe( + Effect.andThen(Effect.flip(Db.transaction(insert(table, 2), { isolationLevel: "serializable" }))), + ), + ) + expect(inner).toBeInstanceOf(Db.TransactionOptionsRejected) + const retried = yield* Db.transaction(Effect.flip(Db.transaction(insert(table, 3), { retry: "contention" }))) + expect(retried).toBeInstanceOf(Db.TransactionOptionsRejected) + expect(yield* ids(table)).toEqual([1]) + }), + ) + + it.effect("applies settings as the first statement of the transaction", () => + Effect.gen(function* () { + statements.length = 0 + const settings = yield* Db.transaction( + Effect.gen(function* () { + const info = yield* Db.Transaction + const rows = yield* Db.query( + Db.sql`SELECT current_setting('transaction_isolation') AS isolation, current_setting('transaction_read_only') AS read_only`, + ) + return { info, row: rows[0] } + }), + { isolationLevel: "serializable", accessMode: "read only", deferrable: true }, + ) + expect(statements[0]).toBe("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE") + expect(settings.row).toEqual({ isolation: "serializable", read_only: "on" }) + expect(settings.info).toEqual({ depth: 0, isolationLevel: "serializable", accessMode: "read only" }) + }), + ) + + it.effect("a write under read only fails as DatabaseError with its SQLSTATE", () => + Effect.gen(function* () { + const table = yield* freshTable + const error = yield* Effect.flip(Db.transaction(insert(table, 1), { accessMode: "read only" })) + assert(error instanceof Db.DatabaseError) + expect(error.sqlState).toBe("25006") + }), + ) + + it.effect("a failed COMMIT is TransactionCommitFailed, not a defect", () => + Effect.gen(function* () { + const parent = yield* freshTable + const child = Db.sql.identifier(`${parent}_child`) + yield* Db.execute( + Db.sql`CREATE TABLE ${child} (id int4, parent int4 REFERENCES ${Db.sql.identifier(parent)}(id) DEFERRABLE INITIALLY DEFERRED)`, + ) + const error = yield* Effect.flip(Db.transaction(Db.execute(Db.sql`INSERT INTO ${child} VALUES (1, 999)`))) + assert(error instanceof Db.TransactionCommitFailed) + expect(error.sqlState).toBe("23503") + expect(error.message).toMatch(/^COMMIT failed/) + expect(yield* Db.query(Db.sql`SELECT * FROM ${child}`)).toEqual([]) + }), + ) + + it.effect("retry: contention re-runs the whole transaction", () => + Effect.gen(function* () { + const table = yield* freshTable + const attempts = yield* Ref.make(0) + yield* Db.transaction( + Effect.gen(function* () { + const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1) + yield* insert(table, attempt) + if (attempt < 3) yield* raiseSqlState("40001") + }), + { retry: { times: 3, schedule: Schedule.spaced("1 millis") } }, + ) + expect(yield* Ref.get(attempts)).toBe(3) + expect(yield* ids(table)).toEqual([3]) + }), + ) + + it.effect("retry gives up after its attempts and never retries a domain error", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + const error = yield* Effect.flip( + Db.transaction(Ref.update(attempts, (n) => n + 1).pipe(Effect.andThen(raiseSqlState("40P01"))), { + retry: { times: 2 }, + }), + ) + assert(error instanceof Db.DatabaseError) + expect(Db.isContention(error)).toBe(true) + expect(yield* Ref.get(attempts)).toBe(3) + + yield* Ref.set(attempts, 0) + yield* Effect.flip( + Db.transaction(Ref.update(attempts, (n) => n + 1).pipe(Effect.andThen(Effect.fail(new Domain("no")))), { + retry: "contention", + }), + ) + expect(yield* Ref.get(attempts)).toBe(1) + }), + ) + + it.effect("retryContention refuses inside an open transaction", () => + Effect.gen(function* () { + const error = yield* Db.transaction(Effect.flip(Db.retryContention(Effect.void))) + expect(error).toBeInstanceOf(Db.TransactionOptionsRejected) + }), + ) + + it.effect("a statement from a fiber that outlives its transaction is a TransactionClosed defect", () => + Effect.gen(function* () { + const table = yield* freshTable + const release = yield* Deferred.make() + const straggler = yield* Db.transaction( + Deferred.await(release).pipe(Effect.andThen(insert(table, 1)), Effect.forkDetach), + ) + yield* Deferred.succeed(release, undefined) + const exit = yield* Fiber.await(straggler) + assert(Exit.isFailure(exit)) + expect(Cause.squash(exit.cause)).toBeInstanceOf(Db.TransactionClosed) + expect(yield* ids(table)).toEqual([]) + }), + ) + + it.effect("shares one connection and one nesting counter with sql.withTransaction", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const table = yield* freshTable + yield* Db.transaction( + Effect.gen(function* () { + yield* insert(table, 1) + yield* sql.withTransaction(sql.unsafe(`INSERT INTO ${table} VALUES (2, '')`).pipe(Effect.andThen(Effect.fail("raw")))).pipe( + Effect.ignore, + ) + }), + ) + expect(yield* ids(table)).toEqual([1]) + const depth = yield* sql.withTransaction(Db.transaction(Effect.map(Effect.service(Db.Transaction), (info) => info.depth))) + expect(depth).toBe(1) + }), + ) + + it.effect("a dialect without transactions fails before sending anything", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const sent: Array = [] + const clickhouse = Db.fromSqlClient(sql, { + dialect: clickhouseDialect, + observe: (statement) => Effect.sync(() => void sent.push(statement.sql)), + }) + const error = yield* Effect.flip(clickhouse.transaction(clickhouse.execute(Db.sql`SELECT 1`))) + assert(error instanceof Db.TransactionUnsupported) + expect(error.dialect).toBe("clickhouse") + expect(sent).toEqual([]) + }), + ) + + it.effect("requireTransaction and transaction as Effect.fn pipe arguments", () => + Effect.gen(function* () { + const table = yield* freshTable + const write = Effect.fn("write")(function* (id: number) { + yield* insert(table, id) + if (id === 2) return yield* Effect.fail(new Domain("two")) + return id + }, Db.requireTransaction) + const op = Effect.fn("op")(function* (id: number) { + return yield* write(id) + }, Db.transaction()) + expect(yield* op(1)).toBe(1) + yield* Effect.flip(op(2)) + expect(yield* ids(table)).toEqual([1]) + }), + ) +}) + +describe("sql templates per dialect", () => { + const name = "it's" + const template = Db.sql`SELECT * FROM ${Db.sql.identifier("app.events")} WHERE name = ${name} AND n IN (${1}, ${2})` + + it.effect("Postgres binds $n", () => + Effect.gen(function* () { + expect(yield* renderTemplate(template, postgresDialect)).toEqual({ + sql: `SELECT * FROM "app"."events" WHERE name = $1 AND n IN ($2, $3)`, + parameters: ["it's", 1, 2], + }) + }), + ) + + it.effect("ClickHouse writes escaped literals", () => + Effect.gen(function* () { + expect(yield* renderTemplate(template, clickhouseDialect)).toEqual({ + sql: "SELECT * FROM app.events WHERE name = 'it\\'s' AND n IN (1, 2)", + parameters: [], + }) + }), + ) +}) diff --git a/src/database/database.ts b/src/database/database.ts new file mode 100644 index 0000000..a6a78f8 --- /dev/null +++ b/src/database/database.ts @@ -0,0 +1,499 @@ +// Executing compiled statements, and transactions over them. +// +// The library still never opens a connection: a `Database` is built from the +// `SqlClient` the caller already has. Transactions are Effect's own +// `withTransaction`, which pins the connection through fiber context, plus what +// it does not do: settings, typed COMMIT/ROLLBACK failures, a guard against +// statements outliving their transaction, and contention retry. See +// `design/transactions.md`. + +import { Cause, Context, Effect, Exit, Layer, Option, Schedule, Schema } from "effect" +import { dual } from "effect/Function" +import * as SqlClient from "effect/sql/SqlClient" +import { isSqlError } from "effect/sql/SqlError" +import { compileCH, compileUnion, CompiledQueryDecodeError, type CompiledQuery } from "../ch/compile" +import { noTransactions, type Dialect, type IsolationLevel, type TransactionSettings } from "../ch/dialect" +import type { QueryBuilderError } from "../ch/errors" +import type { CHQuery } from "../ch/query" +import type { CHUnionQuery } from "../ch/union" +import { + DatabaseError, + TransactionClosed, + TransactionCommitFailed, + TransactionOptionsRejected, + TransactionRollbackFailed, + TransactionUnsupported, + type TransactionError, +} from "./errors" +import { isSqlTemplate, renderTemplate, type SqlTemplate } from "./sql" +import { firstLine, reasonOf, sqlStateOf, toDatabaseError } from "./sql-error" + +/** SQL and the values bound to its placeholders. A `CompiledQuery` is one. */ +export interface Statement { + readonly sql: string + readonly parameters?: ReadonlyArray +} + +/** What `query` and `execute` take: a `sql\`...\`` template, or SQL with its parameters. */ +export type StatementInput = SqlTemplate | Statement + +/** What `run` takes: a built query, a union, or a query already compiled. */ +export type Runnable = CHQuery | CHUnionQuery | CompiledQuery + +/** The decoded row of a `Runnable`. */ +export type RowOf = + Q extends CompiledQuery ? Output : Q extends { readonly _phantom?: { output: infer Output } } ? Output : never + +/** A row codec for `query`. */ +export type RowSchema = Schema.Codec + +export interface RetryOptions { + /** Retries after the first attempt. Default 3. */ + readonly times?: number + /** Delay between attempts. Default exponential from 50 ms. */ + readonly schedule?: Schedule.Schedule +} + +export interface TransactionOptions extends TransactionSettings { + /** + * Re-run the whole transaction, with a fresh BEGIN, on a serialization + * failure or deadlock. Outermost transaction only. `"contention"` uses the + * defaults of `retryContention`. + */ + readonly retry?: "contention" | RetryOptions | undefined +} + +/** The open transaction, as `Transaction` describes it inside the body. */ +export interface TransactionInfo { + /** 0 for the outermost transaction, 1 for its first savepoint, ... */ + readonly depth: number + /** What the outermost transaction set; `undefined` is the server default. */ + readonly isolationLevel: IsolationLevel | undefined + readonly accessMode: "read write" | "read only" | undefined +} + +/** + * The open transaction. Present only inside `transaction`, which also removes + * it from the requirements of its body. `requireTransaction` adds it, which is + * how a helper says it must run inside a transaction. Read it for the depth or + * the settings of the transaction you are in. + */ +export class Transaction extends Context.Service()("@maple-dev/effect-orm/Transaction") {} + +export interface DatabaseApi { + readonly dialect: Dialect + /** + * Compile a query for this database's dialect, run it, and decode its rows. + * `params` fills the query's `param.*` markers. A query compiled elsewhere + * runs as it is, if it was compiled for this dialect. + */ + readonly run: ( + query: Q, + params?: Record, + ) => Effect.Effect>, DatabaseError | QueryBuilderError | CompiledQueryDecodeError> + /** Run a statement and return its rows, decoded through `schema` when given. */ + readonly query: { + (statement: StatementInput): Effect.Effect>, DatabaseError> + (statement: StatementInput, schema: RowSchema): Effect.Effect, DatabaseError | CompiledQueryDecodeError> + } + /** Run a statement whose rows are not wanted: DDL, an advisory lock, a write without RETURNING. */ + readonly execute: (statement: StatementInput) => Effect.Effect + /** + * Run `body` in a transaction. Nested calls become savepoints. A failure, + * defect or interruption in `body` rolls back; domain errors pass through + * unchanged. + */ + readonly transaction: ( + body: Effect.Effect, + options?: TransactionOptions, + ) => Effect.Effect> + /** + * Re-run `effect` when it fails with a serialization failure or deadlock + * (SQLSTATE 40001 / 40P01), including one found at COMMIT. Domain errors are + * never retried. Inside an open transaction it refuses, because only the + * outermost transaction can start again. + */ + readonly retryContention: ( + effect: Effect.Effect, + options?: RetryOptions, + ) => Effect.Effect +} + +export class Database extends Context.Service()("@maple-dev/effect-orm/Database") {} + +export interface FromSqlClientOptions { + /** The dialect the database speaks: `postgresDialect` or `clickhouseDialect`. */ + readonly dialect: Dialect + /** + * Wraps statements run by `execute`. Pass the ClickHouse client's + * `asCommand`, whose query path asks for a JSON result a DDL statement does + * not have; leave unset for Postgres. + */ + readonly command?: (effect: Effect.Effect) => Effect.Effect + /** + * Called with every statement before it runs, including the `SET + * TRANSACTION` a transaction's settings become. For collecting statements + * into one span or log line per request. + */ + readonly observe?: (statement: Statement) => Effect.Effect +} + +// Whether the transaction a fiber's context belongs to is still open. Keyed by +// client so a closed transaction on one database does not flag another. +interface OpenState { + open: boolean + readonly client: SqlClient.SqlClient +} +class TransactionOpen extends Context.Service()("@maple-dev/effect-orm/TransactionOpen") {} + +// A defect from the body, carried through `withTransaction` as a failure so the +// transaction still rolls back and any defect that comes out of it is known to +// be COMMIT's or ROLLBACK's. +class BodyDefect { + readonly _tag = "@maple-dev/effect-orm/BodyDefect" + constructor(readonly cause: Cause.Cause) {} +} + +const CONTENTION_REASONS = new Set(["SerializationError", "DeadlockError"]) +const CONTENTION_STATES = new Set(["40001", "40P01"]) + +/** A serialization failure or deadlock: safe to replay as a fresh transaction. */ +export const isContention = (error: unknown): boolean => + (error instanceof DatabaseError || error instanceof TransactionCommitFailed) && + (CONTENTION_REASONS.has(error.reason) || (error.sqlState !== undefined && CONTENTION_STATES.has(error.sqlState))) + +const settingsOf = (options: TransactionOptions): TransactionSettings => ({ + isolationLevel: options.isolationLevel, + accessMode: options.accessMode, + deferrable: options.deferrable, +}) + +const hasSettings = (settings: TransactionSettings): boolean => + settings.isolationLevel !== undefined || settings.accessMode !== undefined || settings.deferrable !== undefined + +const committed = (state: OpenState) => + Effect.sync(() => { + state.open = false + }) + +/** A `Database` over an Effect `SqlClient`. */ +export const fromSqlClient = (sql: SqlClient.SqlClient, options: FromSqlClientOptions): DatabaseApi => { + const { dialect } = options + const capabilities = dialect.transactions ?? noTransactions + const command = options.command ?? ((effect) => effect) + const observe = options.observe ?? (() => Effect.void) + // Result names exactly as the server sent them: the compiled decoder reads + // the aliases it wrote. + const raw = sql.withoutTransforms() + + // A statement carrying the context of a transaction that has ended would run + // on a connection already back in the pool. That is a bug (a fiber forked in + // the transaction and never joined), so it is a defect, not an error to handle. + const guard = (statement: Statement) => + Effect.flatMap(Effect.serviceOption(TransactionOpen), (state) => + Option.isSome(state) && state.value.client === sql && !state.value.open + ? Effect.die( + new TransactionClosed({ + message: "a statement ran after its transaction ended; join fibers forked inside a transaction before it returns", + sql: statement.sql, + }), + ) + : observe(statement), + ) + + const resolve = (statement: StatementInput): Effect.Effect => + isSqlTemplate(statement) ? renderTemplate(statement, dialect) : Effect.succeed(statement) + + const rows = (statement: Statement) => + guard(statement).pipe( + Effect.andThen(raw.unsafe>(statement.sql, statement.parameters ?? [])), + Effect.map((rows): ReadonlyArray> => rows), + Effect.mapError(toDatabaseError(statement.sql)), + ) + + const decodeWith = + (schema: RowSchema) => + (wire: ReadonlyArray>) => { + const decode = Schema.decodeUnknownEffect(schema) + return Effect.forEach(wire, (row, rowIndex) => + decode(row).pipe( + Effect.mapError( + (cause) => new CompiledQueryDecodeError({ message: `row ${rowIndex} did not match the schema`, rowIndex, cause }), + ), + ), + ) + } + + const query = ((statement: StatementInput, schema?: RowSchema) => + Effect.flatMap( + resolve(statement), + (resolved): Effect.Effect, DatabaseError | CompiledQueryDecodeError> => + schema === undefined ? rows(resolved) : Effect.flatMap(rows(resolved), decodeWith(schema)), + )) as DatabaseApi["query"] + + const execute: DatabaseApi["execute"] = (statement) => + Effect.flatMap(resolve(statement), (resolved) => + guard(resolved).pipe( + Effect.andThen(command(raw.unsafe(resolved.sql, resolved.parameters ?? []))), + Effect.asVoid, + Effect.mapError(toDatabaseError(resolved.sql)), + ), + ) + + const compileFor = ( + runnable: Runnable, + params: Record, + ): Effect.Effect, QueryBuilderError> => { + if ("decodeRows" in runnable) { + return runnable.dialect !== undefined && runnable.dialect !== dialect.name + ? Effect.die( + new DatabaseError({ + message: `a query compiled for ${runnable.dialect} cannot run on a ${dialect.name} database; pass the query to run instead of compiling it`, + sql: runnable.sql, + reason: "DialectMismatch", + cause: undefined, + }), + ) + : Effect.succeed(runnable) + } + return "_tag" in runnable && runnable._tag === "CHUnionQuery" + ? compileUnion(runnable, params, { dialect }) + : compileCH(runnable as CHQuery, params, { dialect }) + } + + const run: DatabaseApi["run"] = (runnable, params = {}) => + Effect.flatMap(compileFor(runnable, params), (compiled) => + Effect.flatMap(rows(compiled), (wire) => compiled.decodeRows(wire)), + ) + + const retryContention = (effect: Effect.Effect, retry?: RetryOptions) => + Effect.flatMap(Effect.serviceOption(sql.transactionService), (open): Effect.Effect => + Option.isSome(open) + ? Effect.fail( + new TransactionOptionsRejected({ + message: + "retryContention inside an open transaction cannot retry: a serialization failure aborts the whole transaction. Retry the outermost transaction instead", + }), + ) + : Effect.retry(effect, { + while: isContention, + times: retry?.times ?? 3, + schedule: retry?.schedule ?? Schedule.exponential("50 millis"), + }), + ) + + const once = ( + body: Effect.Effect, + options: TransactionOptions, + ): Effect.Effect> => + Effect.gen(function* () { + if (capabilities.support === "none") { + return yield* new TransactionUnsupported({ + dialect: dialect.name, + message: `${dialect.name} has no transactions; nothing was sent`, + }) + } + const parent = yield* Effect.serviceOption(sql.transactionService) + const depth = Option.isSome(parent) ? parent.value[1] + 1 : 0 + const settings = settingsOf(options) + if (depth > 0) { + if (hasSettings(settings) || options.retry !== undefined) { + return yield* new TransactionOptionsRejected({ + message: + "a nested transaction cannot set isolationLevel, accessMode, deferrable or retry; they belong to the outermost transaction", + }) + } + if (!capabilities.savepoints) { + return yield* new TransactionUnsupported({ + dialect: dialect.name, + message: `${dialect.name} has no savepoints, so transactions cannot nest`, + }) + } + } else { + if (settings.isolationLevel !== undefined && !capabilities.isolationLevels.includes(settings.isolationLevel)) { + return yield* new TransactionOptionsRejected({ + message: `${dialect.name} does not support isolation level ${settings.isolationLevel}`, + }) + } + if (settings.accessMode !== undefined && !capabilities.accessModes) { + return yield* new TransactionOptionsRejected({ message: `${dialect.name} does not support access modes` }) + } + if (settings.deferrable !== undefined && !capabilities.deferrable) { + return yield* new TransactionOptionsRejected({ message: `${dialect.name} does not support deferrable transactions` }) + } + } + + const outer = yield* Effect.serviceOption(Transaction) + const info: TransactionInfo = + depth === 0 || Option.isNone(outer) + ? { depth, isolationLevel: settings.isolationLevel, accessMode: settings.accessMode } + : { ...outer.value, depth } + const setTransaction = depth === 0 ? capabilities.setTransaction(settings) : undefined + const state: OpenState = { open: true, client: sql } + let bodyExit: Exit.Exit | undefined + + const inner = Effect.gen(function* () { + if (setTransaction !== undefined) yield* execute({ sql: setTransaction }) + return yield* body + }).pipe( + Effect.provideService(Transaction, info), + Effect.provideService(TransactionOpen, state), + Effect.onExit((exit) => + Effect.sync(() => { + bodyExit = exit + }), + ), + Effect.catchCause((cause): Effect.Effect => + Cause.hasDies(cause) && !Cause.hasInterrupts(cause) ? Effect.fail(new BodyDefect(cause)) : Effect.failCause(cause), + ), + ) + + const exit = yield* Effect.exit(sql.withTransaction(inner).pipe(Effect.ensuring(committed(state)))) + if (Exit.isSuccess(exit)) return exit.value + return yield* restoreCause(exit.cause, bodyExit, depth) + }).pipe( + Effect.withSpan("effect_orm.transaction", { + attributes: { + "db.transaction.isolation_level": options.isolationLevel ?? "default", + "db.transaction.access_mode": options.accessMode ?? "default", + }, + }), + ) as Effect.Effect> + + const transaction: DatabaseApi["transaction"] = (body, options = {}) => + options.retry === undefined + ? once(body, options) + : retryContention(once(body, options), options.retry === "contention" ? undefined : options.retry) + + return { dialect, run, query, execute, transaction, retryContention } +} + +/** + * Turn what came out of `withTransaction` back into what the caller should see. + * Effect dies on a failed COMMIT or ROLLBACK, and a dying ROLLBACK replaces the + * body's own exit. Body defects were carried through as `BodyDefect`, so any + * other defect that is a `SqlError` came from transaction control. + */ +const restoreCause = ( + cause: Cause.Cause, + bodyExit: Exit.Exit | undefined, + depth: number, +): Effect.Effect => { + const failure = Cause.findError(cause) + if (failure._tag === "Success" && failure.success instanceof BodyDefect) { + return Effect.failCause(failure.success.cause as Cause.Cause) + } + const defect = Cause.findDefect(cause) + if (defect._tag === "Success" && isSqlError(defect.success)) { + const error = defect.success + if (bodyExit !== undefined && Exit.isSuccess(bodyExit)) { + const sqlState = sqlStateOf(error) + return Effect.fail( + new TransactionCommitFailed({ + message: `${depth === 0 ? "COMMIT" : "releasing the savepoint"} failed: ${firstLine(error)}`, + reason: reasonOf(error), + ...(sqlState === undefined ? undefined : { sqlState }), + cause: error, + }), + ) + } + // The body never ran: BEGIN or SAVEPOINT died, not ROLLBACK. + if (bodyExit === undefined) return Effect.fail(toDatabaseError(depth === 0 ? "BEGIN" : "SAVEPOINT")(error)) + // An interrupted transaction whose ROLLBACK died stays interrupted. + if (Cause.hasInterrupts(bodyExit.cause)) return Effect.failCause(bodyExit.cause as Cause.Cause) + return Effect.fail( + new TransactionRollbackFailed({ + message: `ROLLBACK failed after the transaction body failed: ${firstLine(error)}`, + bodyCause: bodyExit.cause, + cause: error, + }), + ) + } + // BEGIN or SAVEPOINT failed before the body ran. + if (bodyExit === undefined && failure._tag === "Success" && isSqlError(failure.success)) { + return Effect.fail(toDatabaseError(depth === 0 ? "BEGIN" : "SAVEPOINT")(failure.success)) + } + return Effect.failCause(cause as Cause.Cause) +} + +/** A `Database` layer over the `SqlClient` in context. */ +export const layerSqlClient = (options: FromSqlClientOptions): Layer.Layer => + Layer.effect( + Database, + Effect.gen(function* () { + return fromSqlClient(yield* SqlClient.SqlClient, options) + }), + ) + +/** `run` on the `Database` in context. */ +export const run = ( + query: Q, + params?: Record, +): Effect.Effect>, DatabaseError | QueryBuilderError | CompiledQueryDecodeError, Database> => + Effect.flatMap(Effect.service(Database), (db) => db.run(query, params)) + +/** `query` on the `Database` in context. */ +export const query: { + (statement: StatementInput): Effect.Effect>, DatabaseError, Database> + ( + statement: StatementInput, + schema: RowSchema, + ): Effect.Effect, DatabaseError | CompiledQueryDecodeError, Database> +} = ((statement: StatementInput, schema?: RowSchema) => + Effect.flatMap( + Effect.service(Database), + (db): Effect.Effect, DatabaseError | CompiledQueryDecodeError> => + schema === undefined ? db.query(statement) : db.query(statement, schema), + )) as typeof query + +/** `execute` on the `Database` in context. */ +export const execute = (statement: StatementInput): Effect.Effect => + Effect.flatMap(Effect.service(Database), (db) => db.execute(statement)) + +/** + * Mark an effect as correct only inside a transaction. It adds `Transaction` + * to the requirements, so it does not compile until `transaction` wraps it, as + * an `Effect.fn` pipe argument or with `.pipe`: + * + * ```ts + * const revokeFamily = Effect.fn("revokeFamily")(function* (family: string) { ... }, Db.requireTransaction) + * ``` + */ +export const requireTransaction = (self: Effect.Effect): Effect.Effect => + Effect.andThen(Effect.service(Transaction), self) + +/** + * `transaction` on the `Database` in context. Data-first or pipeable, so it + * also fits as an `Effect.fn` pipe argument: + * + * ```ts + * const rotate = Effect.fn("rotate")(function* () { ... }, Db.transaction({ retry: "contention" })) + * ``` + */ +export const transaction: { + ( + options?: TransactionOptions, + ): ( + body: Effect.Effect, + ) => Effect.Effect | Database> + ( + body: Effect.Effect, + options?: TransactionOptions, + ): Effect.Effect | Database> +} = dual( + (args) => Effect.isEffect(args[0]), + (body: Effect.Effect, options?: TransactionOptions) => + Effect.flatMap(Effect.service(Database), (db) => db.transaction(body, options)), +) + +/** `retryContention` on the `Database` in context. Data-first or pipeable. */ +export const retryContention: { + ( + options?: RetryOptions, + ): (effect: Effect.Effect) => Effect.Effect + (effect: Effect.Effect, options?: RetryOptions): Effect.Effect +} = dual( + (args) => Effect.isEffect(args[0]), + (effect: Effect.Effect, options?: RetryOptions) => + Effect.flatMap(Effect.service(Database), (db) => db.retryContention(effect, options)), +) diff --git a/src/database/errors.ts b/src/database/errors.ts new file mode 100644 index 0000000..8cab943 --- /dev/null +++ b/src/database/errors.ts @@ -0,0 +1,73 @@ +import { Schema } from "effect" + +/** + * A statement failed: the server rejected it, or the connection failed while it ran. + * + * `reason` is the driver's classification (`SqlError.reason._tag`: + * `UniqueViolation`, `SerializationError`, `DeadlockError`, ...) and `sqlState` + * the server's error code when the driver reported one, so callers can branch + * without digging through `cause`. + */ +export class DatabaseError extends Schema.TaggedError()("@maple-dev/effect-orm/DatabaseError", { + message: Schema.String, + sql: Schema.String, + reason: Schema.String, + sqlState: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) {} + +/** The database's dialect has no transactions (ClickHouse), or none at this nesting depth. */ +export class TransactionUnsupported extends Schema.TaggedError()( + "@maple-dev/effect-orm/TransactionUnsupported", + { dialect: Schema.String, message: Schema.String }, +) {} + +/** + * Transaction options the dialect does not support, or options on a nested + * transaction, which cannot change what the outer one already started with. + */ +export class TransactionOptionsRejected extends Schema.TaggedError()( + "@maple-dev/effect-orm/TransactionOptionsRejected", + { message: Schema.String }, +) {} + +/** + * COMMIT failed (a deferred constraint, a serialization failure found at + * commit, a dropped connection), or releasing a nested savepoint did. Nothing + * the transaction wrote is kept. + */ +export class TransactionCommitFailed extends Schema.TaggedError()( + "@maple-dev/effect-orm/TransactionCommitFailed", + { + message: Schema.String, + reason: Schema.String, + sqlState: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) {} + +/** + * ROLLBACK failed after the body failed. `bodyCause` is why the body failed; + * `cause` is the rollback's own error. The connection is not reused. + */ +export class TransactionRollbackFailed extends Schema.TaggedError()( + "@maple-dev/effect-orm/TransactionRollbackFailed", + { message: Schema.String, bodyCause: Schema.Defect(), cause: Schema.Defect() }, +) {} + +/** + * A statement ran with the context of a transaction that had already ended, + * most often from a fiber forked inside the transaction and never joined. + * Running it would use a connection that has gone back to the pool. + */ +export class TransactionClosed extends Schema.TaggedError()("@maple-dev/effect-orm/TransactionClosed", { + message: Schema.String, + sql: Schema.String, +}) {} + +export type TransactionError = + | TransactionUnsupported + | TransactionOptionsRejected + | TransactionCommitFailed + | TransactionRollbackFailed + | TransactionClosed diff --git a/src/database/sql-error.ts b/src/database/sql-error.ts new file mode 100644 index 0000000..32a8989 --- /dev/null +++ b/src/database/sql-error.ts @@ -0,0 +1,54 @@ +// Reading a driver error: its message, its classification, its SQLSTATE. +// +// Drivers wrap the server's error in generic ones (`SqlError` around a reason +// around the client's own error), so each reader walks the chain. + +import { isSqlError } from "effect/sql/SqlError" +import { DatabaseError } from "./errors" + +const next = (current: object): unknown => ("reason" in current ? current.reason : "cause" in current ? current.cause : undefined) + +/** The innermost message, first line only. */ +export const firstLine = (cause: unknown): string => { + let current: unknown = cause + let message = String(cause) + for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) { + if ("message" in current && typeof current.message === "string" && current.message.length > 0) message = current.message + const inner = next(current) + if (inner === undefined || inner === current) break + current = inner + } + return message.split("\n")[0]?.trim().slice(0, 500) ?? message +} + +/** + * The server's five-character error code (`40001`, `23505`), when a driver in + * the chain kept it. `@effect/sql-pg` classifies some codes into reasons; + * `@effect/sql-pglite` does not, so the code is the reliable signal. + */ +export const sqlStateOf = (cause: unknown): string | undefined => { + let current: unknown = cause + for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) { + if ("code" in current && typeof current.code === "string" && /^[0-9A-Z]{5}$/.test(current.code)) return current.code + const inner = next(current) + if (inner === undefined || inner === current) break + current = inner + } + return undefined +} + +/** The driver's classification: `SqlError.reason._tag`, or `Unknown`. */ +export const reasonOf = (cause: unknown): string => (isSqlError(cause) ? cause.reason._tag : "Unknown") + +export const toDatabaseError = + (sql: string) => + (cause: unknown): DatabaseError => { + const sqlState = sqlStateOf(cause) + return new DatabaseError({ + message: firstLine(cause), + sql, + reason: reasonOf(cause), + ...(sqlState === undefined ? undefined : { sqlState }), + cause, + }) + } diff --git a/src/database/sql.ts b/src/database/sql.ts new file mode 100644 index 0000000..eb53485 --- /dev/null +++ b/src/database/sql.ts @@ -0,0 +1,100 @@ +// `Db.sql`: a statement written as a template, every `${value}` bound. +// +// The template does not know its database, so it keeps the text and the values +// apart and is rendered by the `Database` that runs it: `$1, $2, ...` for +// Postgres, an escaped literal for ClickHouse, which takes no bound values over +// HTTP. Nothing in a value can become SQL. + +import { Effect } from "effect" +import { checkedLiteral, type Dialect } from "../ch/dialect" +import { QueryBuilderError } from "../ch/errors" +import { DatabaseError } from "./errors" + +const SqlTemplateTag = "@maple-dev/effect-orm/SqlTemplate" +const IdentifierTag = "@maple-dev/effect-orm/SqlIdentifier" + +/** A name written as an identifier, quoted by the dialect. From `sql.identifier`. */ +export interface SqlIdentifier { + readonly _tag: typeof IdentifierTag + readonly name: string +} + +/** A statement from `sql\`...\``: text and values, rendered per dialect when it runs. */ +export interface SqlTemplate { + readonly _tag: typeof SqlTemplateTag + readonly strings: ReadonlyArray + readonly values: ReadonlyArray +} + +/** + * A statement with every `${value}` bound, never spliced into the text. A + * `sql\`...\`` inside another is spliced as SQL, so statements compose. + * + * ```ts + * Db.execute(Db.sql`UPDATE api_keys SET revoked = true WHERE family = ${family}`) + * ``` + */ +export const sql: { + (strings: TemplateStringsArray, ...values: ReadonlyArray): SqlTemplate + /** A table or column name, quoted by the dialect: `sql\`SELECT * FROM ${sql.identifier(table)}\``. */ + readonly identifier: (name: string) => SqlIdentifier +} = Object.assign( + (strings: TemplateStringsArray, ...values: ReadonlyArray): SqlTemplate => ({ + _tag: SqlTemplateTag, + strings: [...strings], + values, + }), + { identifier: (name: string): SqlIdentifier => ({ _tag: IdentifierTag, name }) }, +) + +const isIdentifier = (value: unknown): value is SqlIdentifier => + typeof value === "object" && value !== null && "_tag" in value && value._tag === IdentifierTag + +export const isSqlTemplate = (value: unknown): value is SqlTemplate => + typeof value === "object" && value !== null && "_tag" in value && value._tag === SqlTemplateTag + +// ClickHouse writes identifiers bare, so only plain names are accepted, for +// every dialect: letters, digits and `_`, dotted for `schema.table`. +const PLAIN_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/ + +const identifier = (dialect: Dialect, name: string): string => { + if (!PLAIN_NAME.test(name)) { + throw new QueryBuilderError({ + code: "InvalidLiteral", + message: `sql.identifier: ${JSON.stringify(name)} is not a plain identifier (letters, digits, _, dotted for schema.table)`, + }) + } + return name + .split(".") + .map((segment) => dialect.quoteIdent(segment)) + .join(".") +} + +/** Text with the dialect's placeholders, and the values they bind. */ +export const renderTemplate = ( + template: SqlTemplate, + dialect: Dialect, +): Effect.Effect<{ readonly sql: string; readonly parameters: ReadonlyArray }, DatabaseError> => + Effect.try({ + try: () => { + const parameters: Array = [] + const render = (current: SqlTemplate): string => + current.strings.reduce((text, part, index) => { + if (index === 0) return part + const value = current.values[index - 1] + if (isSqlTemplate(value)) return text + render(value) + part + if (isIdentifier(value)) return text + identifier(dialect, value.name) + part + if (dialect.params._tag === "inline") return text + checkedLiteral(dialect, value, "a sql`` value") + part + parameters.push(value) + return text + dialect.params.placeholder(parameters.length, "") + part + }, "") + return { sql: render(template), parameters } + }, + catch: (cause) => + new DatabaseError({ + message: cause instanceof QueryBuilderError ? cause.message : String(cause), + sql: template.strings.join("?"), + reason: cause instanceof QueryBuilderError ? cause.code : "InvalidLiteral", + cause, + }), + }) diff --git a/src/migrate/driver.ts b/src/migrate/driver.ts index 1a2e929..a01b6d2 100644 --- a/src/migrate/driver.ts +++ b/src/migrate/driver.ts @@ -8,6 +8,7 @@ import { Context, Effect, Layer } from "effect" import * as SqlClient from "effect/sql/SqlClient" +import { firstLine } from "../database/sql-error" import { MigrateSqlError } from "./errors" export interface MigrationDriverApi { @@ -21,19 +22,6 @@ export class MigrationDriver extends Context.Service { - let current: unknown = cause - let message = String(cause) - for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) { - if ("message" in current && typeof current.message === "string" && current.message.length > 0) message = current.message - const next: unknown = "reason" in current ? current.reason : "cause" in current ? current.cause : undefined - if (next === undefined || next === current) break - current = next - } - return message.split("\n")[0]?.trim().slice(0, 500) ?? message -} - const sqlError = (sql: string) => (cause: unknown) => new MigrateSqlError({ message: firstLine(cause), sql, cause }) export interface FromSqlClientOptions { diff --git a/src/pg/dialect.ts b/src/pg/dialect.ts index ddb8778..896e9eb 100644 --- a/src/pg/dialect.ts +++ b/src/pg/dialect.ts @@ -101,4 +101,19 @@ export const postgresDialect: Dialect = { dateTime: PgTimestampLiteral, dateTimeSeconds: timestampSeconds, }, + transactions: { + support: "full", + savepoints: true, + isolationLevels: ["read committed", "repeatable read", "serializable"], + accessModes: true, + deferrable: true, + setTransaction: (settings) => { + const modes = [ + settings.isolationLevel === undefined ? undefined : `ISOLATION LEVEL ${settings.isolationLevel.toUpperCase()}`, + settings.accessMode?.toUpperCase(), + settings.deferrable === undefined ? undefined : settings.deferrable ? "DEFERRABLE" : "NOT DEFERRABLE", + ].filter((mode) => mode !== undefined) + return modes.length === 0 ? undefined : `SET TRANSACTION ${modes.join(", ")}` + }, + }, } diff --git a/src/pg/postgres.test.ts b/src/pg/postgres.test.ts index 1dd604d..df788be 100644 --- a/src/pg/postgres.test.ts +++ b/src/pg/postgres.test.ts @@ -10,7 +10,8 @@ import type { CompiledQuery } from "../ch/compile" import * as CH from "../index" import * as PG from "../postgres" -const db = new PGlite() +// PGlite 0.5 takes the session time zone from the host; the fixtures assume UTC. +const db = new PGlite({ postgresqlconf: "timezone = 'UTC'" }) const events = CH.table( "events", diff --git a/tests/core.postgres.test.ts b/tests/core.postgres.test.ts index 9b55ffa..65627e2 100644 --- a/tests/core.postgres.test.ts +++ b/tests/core.postgres.test.ts @@ -5,7 +5,8 @@ import { Effect } from "effect" import { coreCases, coreSkips, expectedFor, postgresContext } from "./core-cases" import { runOn } from "./postgres-support" -const db = new PGlite() +// PGlite 0.5 takes the session time zone from the host; the fixtures assume UTC. +const db = new PGlite({ postgresqlconf: "timezone = 'UTC'" }) afterAll(() => db.close()) describe("core builder suite on Postgres", () => { diff --git a/tests/database.clickhouse.test.ts b/tests/database.clickhouse.test.ts new file mode 100644 index 0000000..9de4d4c --- /dev/null +++ b/tests/database.clickhouse.test.ts @@ -0,0 +1,85 @@ +// `Database` on a live ClickHouse: queries and statements run, transactions are +// refused before anything is sent. Creates one table in a throwaway database. + +import { ClickhouseClient } from "@effect/sql-clickhouse" +import { Effect, Exit } from "effect" +import { describe, expect, it } from "vitest" +import * as CH from "@maple-dev/effect-orm" +import * as Db from "@maple-dev/effect-orm/database" +import { endpoint } from "./clickhouse-support" + +const user = process.env.EFFECT_ORM_CLICKHOUSE_USER ?? "default" +const password = process.env.EFFECT_ORM_CLICKHOUSE_PASSWORD ?? "" + +const withDatabase = (body: (db: Db.DatabaseApi, sent: Array, client: ClickhouseClient.ClickhouseClient) => Effect.Effect) => + Effect.gen(function* () { + const database = `eo_database_${Date.now()}_${Math.floor(Math.random() * 1e6)}` + const admin = yield* ClickhouseClient.ClickhouseClient + yield* admin.asCommand(admin.unsafe(`CREATE DATABASE ${database}`)) + return yield* Effect.gen(function* () { + const client = yield* ClickhouseClient.ClickhouseClient + const sent: Array = [] + const db = Db.fromSqlClient(client, { + dialect: CH.clickhouseDialect, + command: client.asCommand, + observe: (statement) => Effect.sync(() => void sent.push(statement.sql)), + }) + return yield* body(db, sent, client) + }).pipe( + Effect.provide(ClickhouseClient.layer({ url: endpoint!, username: user, password, database })), + Effect.ensuring(Effect.orDie(admin.asCommand(admin.unsafe(`DROP DATABASE IF EXISTS ${database} SYNC`)))), + ) + }).pipe(Effect.provide(ClickhouseClient.layer({ url: endpoint!, username: user, password, database: "default" }))) + +describe("database", () => { + describe.skipIf(!endpoint)("live ClickHouse", () => { + it("runs queries and sql templates", async () => { + const rows = await Effect.runPromise( + withDatabase((db) => + Effect.gen(function* () { + yield* db.execute(Db.sql`CREATE TABLE events (Id UInt32, Name String) ENGINE = MergeTree ORDER BY Id`) + yield* db.execute(Db.sql`INSERT INTO events VALUES (${1}, ${"a"}), (${2}, ${"it's"})`) + const Events = CH.table("events", { Id: CH.uint32, Name: CH.string }) + return yield* db.run(CH.from(Events).select("Id", "Name").orderBy(["Id", "asc"])) + }), + ), + ) + expect(rows).toEqual([ + { Id: 1, Name: "a" }, + { Id: 2, Name: "it's" }, + ]) + }) + + it("refuses a transaction before sending anything", async () => { + const result = await Effect.runPromise( + withDatabase((db, sent) => + Effect.gen(function* () { + const error = yield* Effect.flip(db.transaction(db.execute(Db.sql`SELECT 1`))) + return { error, sent: [...sent] } + }), + ), + ) + expect(result.error).toBeInstanceOf(Db.TransactionUnsupported) + expect(result.sent).toEqual([]) + }) + + // Why the dialect refuses: Effect's ClickHouse client has no session. Its + // withTransaction sends BEGIN through the query path, which appends FORMAT + // JSON and fails to parse; under asCommand the BEGIN reaches the server, + // which a default server rejects, and which a server with experimental + // transactions accepts and forgets (design/transactions.md section 3). + // Pinned so a server or driver change that alters it is noticed. + it("Effect's ClickHouse withTransaction fails at BEGIN", async () => { + const [query, command] = await Effect.runPromise( + withDatabase((_db, _sent, client) => + Effect.all([ + Effect.exit(client.withTransaction(client.unsafe("SELECT 1"))), + Effect.exit(client.asCommand(client.withTransaction(client.unsafe("SELECT 1")))), + ]), + ), + ) + expect(String(Exit.isFailure(query) ? query.cause : "")).toMatch(/Syntax error/) + expect(String(Exit.isFailure(command) ? command.cause : "")).toMatch(/NOT_IMPLEMENTED|not supported/i) + }) + }) +}) diff --git a/tests/dialect.postgres.test.ts b/tests/dialect.postgres.test.ts index 09f0c77..77501f5 100644 --- a/tests/dialect.postgres.test.ts +++ b/tests/dialect.postgres.test.ts @@ -5,7 +5,8 @@ import { Effect } from "effect" import { postgresCases } from "./dialect-cases.postgres" import { runOn } from "./postgres-support" -const db = new PGlite() +// PGlite 0.5 takes the session time zone from the host; the fixtures assume UTC. +const db = new PGlite({ postgresqlconf: "timezone = 'UTC'" }) afterAll(() => db.close()) describe("postgres dialect fixtures", () => { diff --git a/tests/package-consumer.mts b/tests/package-consumer.mts index 5581878..895d5a9 100644 --- a/tests/package-consumer.mts +++ b/tests/package-consumer.mts @@ -11,6 +11,7 @@ import { runCli } from "@maple-dev/effect-orm/benchmark/cli" import * as SQL from "@maple-dev/effect-orm/sql" import * as S from "@maple-dev/effect-orm/schema" import * as Migrate from "@maple-dev/effect-orm/migrate" +import * as Db from "@maple-dev/effect-orm/database" import { defineConfig } from "@maple-dev/effect-orm/kit" const events = CH.table("events", { id: T.uint64, name: T.string }) @@ -87,3 +88,6 @@ assert.equal( "http://localhost:8123", ) assert.equal(typeof runCli, "function") + +assert.equal(typeof Db.fromSqlClient, "function") +assert.equal(Db.isContention(new Db.DatabaseError({ message: "x", sql: "", reason: "SerializationError", cause: undefined })), true) diff --git a/tsdown.config.ts b/tsdown.config.ts index ade7019..2277429 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ postgres: "./src/postgres.ts", schema: "./src/schema.ts", migrate: "./src/migrate.ts", + database: "./src/database.ts", kit: "./src/kit.ts", "kit/bin": "./src/kit/bin.ts", "benchmark/index": "./src/benchmark/index.ts",