diff --git a/.changeset/quiet-sqlite-dialects.md b/.changeset/quiet-sqlite-dialects.md new file mode 100644 index 0000000..628a175 --- /dev/null +++ b/.changeset/quiet-sqlite-dialects.md @@ -0,0 +1,8 @@ +--- +"@maple-dev/effect-orm": minor +--- + +Add the `/sqlite` query entry with SQLite codecs, functions and positional bindings. Use +`sqliteDialect` with an Effect SQLite SqlClient, or `d1Dialect` for Cloudflare D1's binding limit +and explicit refusal of interactive transactions. `table` is a query schema only: SQLite DDL and +migration generation are not implemented yet. diff --git a/README.md b/README.md index 4863efe..4ad708a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ > **Alpha.** The API is still changing and minor releases may break it. Pin an exact version, > read the [changelog](./CHANGELOG.md) before upgrading, and expect rough edges. -Type-safe ClickHouse and Postgres queries, result decoding, and reproducible benchmarks for Effect and TypeScript. +Type-safe ClickHouse, Postgres and SQLite queries, result decoding, and reproducible benchmarks for Effect and TypeScript. Formerly `@maple-dev/effect-clickhouse`. +SQLite and Cloudflare D1 use the same builder and row decoder through the new +[`/sqlite` entry](./docs/sqlite.md). SQLite schema/migration generation is not implemented. + [Read the documentation](https://effect-clickhouse.maple.dev) · [Getting started](./docs/getting-started.md) · [Recipes](./docs/recipes.md) diff --git a/bun.lock b/bun.lock index db92323..a8a9d5a 100644 --- a/bun.lock +++ b/bun.lock @@ -10,13 +10,16 @@ "@effect/language-service": "^0.87.3", "@effect/platform-bun": "4.0.0", "@effect/sql-clickhouse": "4.0.0", + "@effect/sql-d1": "4.0.0", "@effect/sql-pglite": "4.0.0", + "@effect/sql-sqlite-node": "4.0.0", "@effect/vitest": "4.0.0", "@electric-sql/pglite": "0.5.8", "@types/node": "^22.10.2", "effect": "4.0.0", "expect-type": "^1.3.0", "fast-check": "^4.9.0", + "miniflare": "4.20260730.0", "tsdown": "^0.22.14", "typescript": "^7.0.2", "vitest": "^4.1.9", @@ -68,6 +71,20 @@ "@clickhouse/client-common": ["@clickhouse/client-common@1.21.0", "", {}, "sha512-UjvA32aaLKbQ5U735qAGdzohrOrfU8zFoOWIOWorWQk+7UzHZxq6pf4Uh/wQ08zAMb9cmkd9h9N1FV8zP8UCOg=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260730.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260730.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260730.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260730.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260730.1", "", { "os": "win32", "cpu": "x64" }, "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20261024.1", "", {}, "sha512-sNhbgBsuVvmumSurgCcIrXShqACPfRt/XlYFstBeANu5BHEJhXW+e7PmHxDNYRXxKYZV57toUfkLwFp8/0SZKA=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@effect/language-service": ["@effect/language-service@0.87.3", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g1OmRBZF5chbW9QMNoirPo5Bb4zzU2HfP88tR7/VSZEUxHJ1/qVb8AQ0kUh2ggctLH1I715yCwP4gkw56PTjpQ=="], "@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=="], @@ -78,14 +95,78 @@ "@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-d1": ["@effect/sql-d1@4.0.0", "", { "dependencies": { "@cloudflare/workers-types": "^5.20260926.1" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-pmkzncZCGD6CLyvc89keEBU85h+gHVCu/LKQnThd+ruYSUbG0R9jYA+lnk3R8Djp0wQW6SbsbmHQuhopIYyo3Q=="], + "@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/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.0", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-xyUSLY7v55iMgGxrjFrTo5ngixgQ3jLmOQ+AX8CCazO/caxuMM0oq8zb974FAcvQHRrpMEWU7Vnoo+rxct7PAQ=="], + "@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.5.8", "", {}, "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@manypkg/find-root": ["@manypkg/find-root@3.1.0", "", { "dependencies": { "@manypkg/tools": "^2.1.0" } }, "sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA=="], "@manypkg/get-packages": ["@manypkg/get-packages@3.1.0", "", { "dependencies": { "@manypkg/find-root": "^3.1.0", "@manypkg/tools": "^2.1.0" } }, "sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg=="], @@ -96,6 +177,12 @@ "@pnpm/deps.graph-sequencer": ["@pnpm/deps.graph-sequencer@1100.0.1", "", {}, "sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@quansync/fs": ["@quansync/fs@1.1.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg=="], "@redis/bloom": ["@redis/bloom@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA=="], @@ -140,6 +227,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -266,6 +357,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -276,6 +369,8 @@ "empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -308,6 +403,8 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "launch-editor": ["launch-editor@2.14.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.10.0" } }, "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ=="], "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=="], @@ -336,6 +433,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "miniflare": ["miniflare@4.20260730.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260730.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg=="], + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], @@ -364,6 +463,8 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + "shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -376,6 +477,8 @@ "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], @@ -388,11 +491,13 @@ "tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], - "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -404,16 +509,26 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "workerd": ["workerd@1.20260730.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260730.1", "@cloudflare/workerd-darwin-arm64": "1.20260730.1", "@cloudflare/workerd-linux-64": "1.20260730.1", "@cloudflare/workerd-linux-arm64": "1.20260730.1", "@cloudflare/workerd-windows-64": "1.20260730.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "yuku-ast": ["yuku-ast@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7" } }, "sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ=="], "yuku-codegen": ["yuku-codegen@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7" }, "optionalDependencies": { "@yuku-codegen/binding-android-arm64": "0.8.7", "@yuku-codegen/binding-darwin-arm64": "0.8.7", "@yuku-codegen/binding-darwin-x64": "0.8.7", "@yuku-codegen/binding-freebsd-x64": "0.8.7", "@yuku-codegen/binding-linux-arm-gnu": "0.8.7", "@yuku-codegen/binding-linux-arm-musl": "0.8.7", "@yuku-codegen/binding-linux-arm64-gnu": "0.8.7", "@yuku-codegen/binding-linux-arm64-musl": "0.8.7", "@yuku-codegen/binding-linux-x64-gnu": "0.8.7", "@yuku-codegen/binding-linux-x64-musl": "0.8.7", "@yuku-codegen/binding-win32-arm64": "0.8.7", "@yuku-codegen/binding-win32-x64": "0.8.7" } }, "sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw=="], "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/undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + + "@effect/platform-node-shared/ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "@effect/sql-clickhouse/@clickhouse/client": ["@clickhouse/client@1.23.1", "", {}, "sha512-vs3/Zc1dHvT171btW5nMoPsPCJ6QVJ5pp7obxzO5sjqwFx/jjz9wwCAqcFOdc2DhprugDBaVn+4dVY8hG3A9nw=="], } } diff --git a/docs/README.md b/docs/README.md index 7a61d11..cbdb224 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,3 +99,7 @@ The optional `@maple-dev/effect-orm/benchmark` entry point and bundled `ch-bench` CLI measure real queries, compare fixed workloads, and save evidence. See [Benchmarking](./benchmarking.md) and the [agent playbook](./benchmark-agent.md). The SQL builders remain driver-free. + +## SQLite and D1 + +[SQLite and D1](./sqlite.md) covers `@maple-dev/effect-orm/sqlite`, its codecs and functions, Effect SQL drivers, tenant checks and transaction limits. SQLite migration generation is not implemented. diff --git a/docs/reference.md b/docs/reference.md index abc2d46..4d5e6ed 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -5,9 +5,10 @@ The package has one entry per database, each self-contained: ```ts import * as CH from "@maple-dev/effect-orm/clickhouse" import * as PG from "@maple-dev/effect-orm/postgres" +import * as SQLite from "@maple-dev/effect-orm/sqlite" ``` -Both carry the whole [query builder](#query-builder-both-entries). Each adds its own column +All carry the whole [query builder](#query-builder-every-entry). Each adds its own column types, functions, table definitions and a `compile` that defaults to its dialect: [`/clickhouse`](#clickhouse) and [`/postgres`](#postgres). Everything else is on a [subpath](#other-subpaths) for a narrower job. @@ -32,9 +33,9 @@ uniformly, which some codebases prefer for exactly this reason. --- -## Query builder (both entries) +## Query builder (every entry) -Everything in this section is exported, identically, from both `/clickhouse` and `/postgres`. +Everything in this section is exported, identically, from `/clickhouse`, `/postgres` and `/sqlite`. ### Query construction @@ -46,7 +47,7 @@ Everything in this section is exported, identically, from both `/clickhouse` and | `unionAll` | `(...queries) => CHUnionQuery` | | `update` | `(table) => CHUpdateStart`, then `CHUpdate`: `.set(record \| fn)`, `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)`. See [Updating and deleting](./updates-and-deletes.md) | | `deleteFrom` | `(table) => CHDelete`: `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)` | -| `insertInto` | `(table) => CHInsertStart`, then `CHInsert`; `.values(row \| rows)` or `.select(query)` sets its rows, `.settings(record)` ClickHouse `SETTINGS`, `.returning(...)` the RETURNING list, `.onConflictDoNothing(options?)` / `.onConflictDoUpdate(options)` the ON CONFLICT clause (Postgres). See [Inserting rows](./inserts.md) | +| `insertInto` | `(table) => CHInsertStart`, then `CHInsert`; `.values(row \| rows)` or `.select(query)` sets its rows, `.settings(record)` ClickHouse `SETTINGS`, `.returning(...)` the RETURNING list, `.onConflictDoNothing(options?)` / `.onConflictDoUpdate(options)` the ON CONFLICT clause (Postgres and SQLite). See [Inserting rows](./inserts.md) | Tables come from the dialect's `table`: [`CH.table`](#tables-and-ddl) or [`PG.table`](#tables-and-ddl-1). @@ -530,3 +531,26 @@ verification, and JSON protocol. Note `/sql` exports a `compile` (fragment → string) distinct from the dialect entries' `compile` (query → `CompiledQuery`), and a `when` distinct from the query builder's `when` (optional conditions). + +## SQLite and D1 + +`@maple-dev/effect-orm/sqlite` exports the shared query builder and the four compile functions. +`sqliteDialect` is the default; `d1Dialect` uses the same SQL with a 100-binding limit and no +interactive transactions. See [SQLite and D1](./sqlite.md) for driver wiring and limits. + +| Export | Purpose | +| --- | --- | +| `SqliteType` | A SQLite SQL type with its Effect codec | +| `text`, `integer`, `real` | Text, safe integer and finite number codecs | +| `boolean` | A boolean encoded as 0/1; other integers fail decoding | +| `timestamp` | An instant stored as ISO text | +| `blob` | Bytes from a Uint8Array or D1's array wire representation | +| `json(schema)` | JSON text decoded through the supplied schema | +| `nullable(type)`, `custom`, `brand` | Type composition | +| `table(name, columns, options?)`, `TableOptions` | A query schema, including `tenantColumn`, defaults and computed-column metadata; no generated DDL | +| `table(name, ExternalTableDefinition)`, `ExternalTableDefinition` | `{ external: true, columns, tenantColumn? }`, the same query schema in the `/clickhouse` and `/postgres` form | +| `count`, `countDistinct`, `countIf`, `sum`, `avg`, `min`, `max` | SQLite aggregates; the last four can return SQL NULL | +| `lower`, `upper`, `length`, `coalesce` | SQLite scalar functions | + +SQLite's table definition is for query typing and decoding. The schema/kit/migrate entries +currently support ClickHouse and Postgres DDL only. diff --git a/docs/sqlite.md b/docs/sqlite.md new file mode 100644 index 0000000..2491ca9 --- /dev/null +++ b/docs/sqlite.md @@ -0,0 +1,115 @@ +# SQLite and Cloudflare D1 + +Import `@maple-dev/effect-orm/sqlite` for the shared query builder, SQLite codecs and functions. +Compilation uses positional `?` bindings. Each occurrence of a parameter gets its own binding. +The package opens no connection: use the Effect `SqlClient` you already provide. + +## Define and compile a query + +```ts title="sqlite-quickstart.ts" +import * as SQLite from "@maple-dev/effect-orm/sqlite" + +export const Acks = SQLite.table("acknowledgments", { + id: SQLite.text, + org_id: SQLite.text, + entry_id: SQLite.text, +}, { tenantColumn: "org_id" }) + +export const byOrg = SQLite.from(Acks) + .select("entry_id") + .where(($) => [$.org_id.eq(SQLite.param.string("orgId"))]) + .orderBy(["entry_id", "asc"]) + +export const compiled = SQLite.compileUnsafe(byOrg, { orgId: "org_1" }) +// compiled.parameters is ["org_1"]; compiled.tenantScope is "single-tenant". +``` + +`table` describes the query schema. Manage its actual DDL and migrations outside this package. +Unlike the ClickHouse and Postgres entries, SQLite has no schema diff, introspection, migration +ledger or kit dialect yet. A `table` value here cannot be passed to the DDL generators. + +The entry exports `text`, `integer` (a safe JavaScript integer), `real` (finite numbers), +`boolean` (0/1), `blob`, `timestamp` (ISO text), `json(schema)`, `nullable`, `custom` and `brand`. +`blob` accepts the Uint8Array wire form from Node and the array-of-bytes form from D1. +`json` always uses the supplied schema to validate decoded JSON. +Use `text` when a stored date/time must remain a string. + +SQLite aggregates include `count`, `countDistinct`, `countIf`, `sum`, `avg`, `min` and `max`. +The last four return NULL over an empty input. Scalar helpers include `lower`, `upper`, +`length` and `coalesce`. SQLite's own text and case rules apply. Foreign built-in function +sets, such as ClickHouse's `count()`, fail compilation instead of producing invalid SQL. + +## Execute on SQLite + +Install a compatible Effect SQL driver, for example `@effect/sql-sqlite-node` on Node 24+. +No driver is a runtime dependency of this package. Use current SQLite with RETURNING support. + +```ts title="sqlite-node.ts" +import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient" +import { Layer } from "effect" +import * as Db from "@maple-dev/effect-orm/database" +import { sqliteDialect } from "@maple-dev/effect-orm/sqlite" + +export const SQLiteLive = Db.layerSqlClient({ + dialect: sqliteDialect, + requireTenantScope: true, +}).pipe(Layer.provide(SqliteClient.layer({ filename: ":memory:" }))) +``` + +`Db.run(query, params)` compiles for the database dialect and decodes the selected rows. +SQLite transactions and nested savepoints use the supplied client's `withTransaction`. +`serializable` is the only accepted explicit isolation level. Per-transaction access modes +and deferrable settings are refused. The driver must support the declared transaction behavior; +use the D1 profile for a D1 client. + +## Execute on D1 + +Install `@effect/sql-d1` with a compatible Effect version. Provide the Worker's D1 binding: + +```ts title="sqlite-d-one.ts" +import * as D1Client from "@effect/sql-d1/D1Client" +import { Layer } from "effect" +import * as Db from "@maple-dev/effect-orm/database" +import { d1Dialect } from "@maple-dev/effect-orm/sqlite" + +export const d1DatabaseLayer = (db: D1Client.D1ClientConfig["db"]) => + Db.layerSqlClient({ dialect: d1Dialect, requireTenantScope: true }).pipe( + Layer.provide(D1Client.layer({ db })), + ) +``` + +Use `d1Dialect` when compiling a query separately as well: +`SQLite.compile(query, params, { dialect: SQLite.d1Dialect })`. A query compiled for `sqlite` +cannot later run on a database configured as `d1`; pass the builder to `Db.run` instead. + +D1 compilation refuses more than [100 bound parameters per statement](https://developers.cloudflare.com/d1/platform/limits/). +It never splits a write implicitly. Chunk an import explicitly if needed; several chunks do +not make one atomic write. SQLite compilation uses its modern default limit of 32766; a driver +with a lower limit needs a custom dialect profile. + +`Database.transaction` on D1 returns `TransactionUnsupported` before executing its body. +D1's [atomic batch API](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch) +executes a fixed list of statements. It cannot run an arbitrary Effect transaction body with +reads that determine later writes. There is no ORM batch API in this first implementation. + +## Safety and syntax limits + +Derived schemas validate returned rows. A row with the wrong type returns +`CompiledQueryDecodeError`. Input values use each column's encoder before binding. + +Tenant enforcement is **opt-in**, through `requireTenantScope: true`. A compiled query over a +tenant table must pin its `tenantColumn` to one value, or explicitly use `.crossTenant()` for +an administrative read/write. Raw SQL passed to `query` or `execute` has no tenant proof and +bypasses this guard. It is not a replacement for an application's authorization checks. + +The dialect handles OFFSET without LIMIT, branch-local ordering/limits in UNION ALL, and +INSERT SELECT with ON CONFLICT. It refuses these unsupported forms at compile time: + +- `DISTINCT ON`, row locks and `ILIKE`. Use `LIKE` with SQLite's own case rules. +- `ON CONSTRAINT` conflict targets. Name the indexed columns instead. +- Per-cell `DEFAULT` in a VALUES tuple. Omit a defaulted column from every row in the statement, + or group rows by their present columns. All-default rows are not supported by the builder. +- ClickHouse FORMAT/SETTINGS and built-in functions from another dialect. + +The tests run real SQLite through `@effect/sql-sqlite-node` and local workerd/D1 through +Miniflare. They require no deployed database or Cloudflare credentials. diff --git a/package.json b/package.json index 079ebcd..5238372 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@maple-dev/effect-orm", "version": "0.7.2", - "description": "Type-safe ClickHouse and Postgres queries, result decoding, and reproducible benchmarks for Effect and TypeScript", + "description": "Type-safe ClickHouse, Postgres and SQLite queries, result decoding, and reproducible benchmarks for Effect and TypeScript", "keywords": [ "clickhouse", "effect", @@ -9,7 +9,9 @@ "postgres", "query-builder", "sql", - "typescript" + "typescript", + "sqlite", + "cloudflare-d1" ], "homepage": "https://effect-clickhouse.maple.dev", "bugs": { @@ -97,6 +99,11 @@ "types": "./dist/benchmark/cli.d.mts", "import": "./dist/benchmark/cli.mjs", "default": "./dist/benchmark/cli.mjs" + }, + "./sqlite": { + "types": "./dist/sqlite.d.mts", + "import": "./dist/sqlite.mjs", + "default": "./dist/sqlite.mjs" } }, "publishConfig": { @@ -121,13 +128,16 @@ "@effect/language-service": "^0.87.3", "@effect/platform-bun": "4.0.0", "@effect/sql-clickhouse": "4.0.0", + "@effect/sql-d1": "4.0.0", "@effect/sql-pglite": "4.0.0", + "@effect/sql-sqlite-node": "4.0.0", "@effect/vitest": "4.0.0", "@electric-sql/pglite": "0.5.8", "@types/node": "^22.10.2", "effect": "4.0.0", "expect-type": "^1.3.0", "fast-check": "^4.9.0", + "miniflare": "4.20260730.0", "tsdown": "^0.22.14", "typescript": "^7.0.2", "vitest": "^4.1.9" diff --git a/scripts/check-exports-documented.mjs b/scripts/check-exports-documented.mjs index 93bec71..0aa5ff8 100644 --- a/scripts/check-exports-documented.mjs +++ b/scripts/check-exports-documented.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Every export of the two dialect entries is named in docs/reference.md, whose +// Every export of the three dialect entries is named in docs/reference.md, whose // first line claims to be the catalog. A claim like that decays the moment // anything is added, so it is checked rather than promised. // @@ -11,7 +11,7 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" const root = join(dirname(fileURLToPath(import.meta.url)), "..") -const entries = ["clickhouse", "postgres"] +const entries = ["clickhouse", "postgres", "sqlite"] const exportsOf = (entry) => { const dts = join(root, `dist/${entry}.d.mts`) diff --git a/src/ch/compile.ts b/src/ch/compile.ts index 0aaaac1..cb2a4e5 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -936,7 +936,9 @@ function compileInner< const sqlQuery: SqlQuery = { distinct: state.distinct !== undefined, distinctOn: Array.isArray(state.distinct) - ? (state.distinct.length === 0 + ? (currentDialect().clauses.distinctOn === false + ? fail>(new QueryBuilderDefect({ message: `${currentDialect().name} has no DISTINCT ON clause` }), []) + : state.distinct.length === 0 ? fail>(new QueryBuilderDefect({ message: "CHQuery: distinctOn() needs at least one key" }), []) : state.distinct ).map((key: string) => { @@ -967,7 +969,9 @@ function compileInner< orderBy: state.orderByFn === undefined ? orderByClause(state.orderBySpecs).map(raw) : orderByExprs(state.orderByFn($)).map(raw), - limit: rowCount("limit", state.limitValue), + limit: state.limitValue == null && state.offsetValue != null && currentDialect().clauses.offsetRequiresLimit === true + ? raw("-1") + : rowCount("limit", state.limitValue), offset: rowCount("offset", state.offsetValue), format: options?.skipFormat ? undefined : formatClause(state.formatValue), } @@ -1442,7 +1446,9 @@ function compileUnionInner, Params extends Re : "cross-tenant" const parenthesize = currentDialect().clauses.parenthesizeUnionBranches - let sql = subQueries.map((q) => (parenthesize ? `(\n${q.sql}\n)` : q.sql)).join("\nUNION ALL\n") + let sql = subQueries.map((q) => currentDialect().clauses.wrapUnionBranches === true + ? `SELECT * FROM (\n${q.sql}\n) AS ${quoteIdent("__union_branch")}` + : parenthesize ? `(\n${q.sql}\n)` : q.sql).join("\nUNION ALL\n") // Wrap in outer SELECT if ordering/pagination is needed const hasOuter = @@ -1455,6 +1461,8 @@ function compileUnionInner, Params extends Re } if (state.outerLimitValue != null) { sql += `\nLIMIT ${compileSqlFragment(rowCount("limit", state.outerLimitValue)!)}` + } else if (state.outerOffsetValue != null && currentDialect().clauses.offsetRequiresLimit === true) { + sql += "\nLIMIT -1" } if (state.outerOffsetValue != null) { sql += `\nOFFSET ${compileSqlFragment(rowCount("offset", state.outerOffsetValue)!)}` @@ -1684,6 +1692,9 @@ const onConflictClause = ( let target = "" if (conflict.target !== undefined) { if ("constraint" in conflict.target) { + if (dialect.clauses.onConflictConstraint === false) { + return fail(new QueryBuilderDefect({ message: `${where}: ${dialect.name} has no ON CONSTRAINT conflict target; name its columns instead` }), "") + } if (conflict.targetWhere !== undefined) { return fail(new QueryBuilderDefect({ message: `${where}: targetWhere needs a column target, not a constraint` }), "") } @@ -1778,7 +1789,9 @@ const valueCells = (table: Table, params: Record = { ...params } let next = 0 const cell = (column: string, value: unknown, context: string): string => { - if (value === undefined) return "DEFAULT" + if (value === undefined) return currentDialect().clauses.defaultInValues === false + ? fail(new QueryBuilderDefect({ message: `${where}: ${currentDialect().name} has no per-cell DEFAULT in VALUES; group rows by their present columns` }), "NULL") + : "DEFAULT" if (isExprLike(value)) return compileSqlFragment(value.toFragment()) const wire = encodeValue(table.columns[column]!.literalSchema, value, `${where}: ${context}, column ${column}`) let name = `${VALUE_PARAM}${next++}` @@ -1954,7 +1967,9 @@ function compileInsert(insert: CHInsert, params: Record ({ dialect: dialect.name, + ilike: dialect.ilike, quoteIdent: dialect.quoteIdent, quoteString: (value) => checked(dialect, dialect.quoteString(value), "a string literal"), literal: (value, context) => checkedLiteral(dialect, value, context), diff --git a/src/ch/expr.ts b/src/ch/expr.ts index 99773df..34bb9f1 100644 --- a/src/ch/expr.ts +++ b/src/ch/expr.ts @@ -13,7 +13,7 @@ import { activeSqlSyntax } from "../sql/sql-syntax" import { activeLiteralBinder } from "../sql/literal-binder" import { chDateTimeLiteral, CHFloatResult, CHNumber, string as chString, type CHType, type InferEncoded, type InferTS } from "./types" import { encodeColumnLiteral, isSubnormal, numberText, sqlLiteral } from "./literal" -import { QueryBuilderError } from "./errors" +import { QueryBuilderDefect, QueryBuilderError } from "./errors" import { fail } from "./failure" import { markTenantColumn, markTenantPredicate, tenantColumnOf, tenantPredicatesOf } from "./tenant" @@ -533,7 +533,9 @@ export function makeExpr( like: (pattern) => makeCond(known(() => `${compile(fragment)} LIKE ${likePattern(pattern)}`)), notLike: (pattern) => makeCond(known(() => `${compile(fragment)} NOT LIKE ${likePattern(pattern)}`)), - ilike: (pattern) => makeCond(known(() => `${compile(fragment)} ILIKE ${likePattern(pattern)}`)), + ilike: (pattern) => makeCond(known(() => activeSqlSyntax()?.ilike === false + ? fail(new QueryBuilderDefect({ message: `ILIKE is not supported by the ${activeSqlSyntax()?.dialect} dialect; use LIKE with SQLite's own case rules` }), "FALSE") + : `${compile(fragment)} ILIKE ${likePattern(pattern)}`)), in_: (...values) => inCond(fragment, "IN", values.map((v) => () => operand(v))), notIn: (...values) => inCond(fragment, "NOT IN", values.map((v) => () => operand(v))), diff --git a/src/ch/functions/builtin.ts b/src/ch/functions/builtin.ts index 766d2a5..7e63f68 100644 --- a/src/ch/functions/builtin.ts +++ b/src/ch/functions/builtin.ts @@ -21,10 +21,11 @@ import { QueryBuilderDefect } from "../errors" import { type Condition, type Expr, makeCond, makeExpr, makeUntypedExpr, type ParamsIn, toFragment } from "../expr" import type { FnResult } from "../define-fn" -export type FunctionSet = "clickhouse" | "postgres" +export type FunctionSet = "clickhouse" | "postgres" | "sqlite" export type BuiltinKind = "scalar" | "aggregate" | "window" const setLabel: Record = { + sqlite: "a SQLite function (from @maple-dev/effect-orm/sqlite)", clickhouse: "a ClickHouse function (from @maple-dev/effect-orm/clickhouse)", postgres: "a Postgres function (from @maple-dev/effect-orm/postgres)", } diff --git a/src/sql/sql-syntax.ts b/src/sql/sql-syntax.ts index bf0fe19..89106c7 100644 --- a/src/sql/sql-syntax.ts +++ b/src/sql/sql-syntax.ts @@ -11,6 +11,8 @@ import type { DateTime } from "effect" export interface SqlSyntax { /** The dialect's name (`clickhouse`, `postgres`, …), for the few choices expression code makes per dialect. */ readonly dialect: string + /** False when the dialect has no ILIKE operator. */ + readonly ilike?: boolean /** One identifier (a column, alias, table or schema name), quoted as needed. */ readonly quoteIdent: (name: string) => string /** A string as a quoted literal. */ diff --git a/src/sqlite.ts b/src/sqlite.ts new file mode 100644 index 0000000..d8c0df2 --- /dev/null +++ b/src/sqlite.ts @@ -0,0 +1,63 @@ +// @maple-dev/effect-orm/sqlite +// +// SQLite and Cloudflare D1 queries, codecs, functions and execution through an +// Effect SqlClient. `table` is a query schema only: this entry writes no DDL +// and generates no migrations yet. See docs/sqlite.md. +import { compileCH, compileCHRaw, compileCHUnsafe, compileUnion as compileUnionCH, compileUnionUnsafe as compileUnionUnsafeCH } from "./ch/compile" +import { compiled, compiledUnsafe } from "./ch/failure" +import { table as queryTable, type Table, type TableOptions } from "./ch/table" +import type { ColumnDefs } from "./ch/types" +import { sqliteDialect } from "./sqlite/dialect" + +export * from "./core" +export * from "./sqlite/types" +export * from "./sqlite/functions" +export type { TableOptions } + +/** A table another tool owns, as on `/clickhouse` and `/postgres`. */ +export interface ExternalTableDefinition { + readonly external: true + readonly columns: Columns + readonly tenantColumn?: keyof Columns & string +} + +/** + * A SQLite query schema: `table(name, columns, options?)`, or + * `table(name, { external: true, columns, tenantColumn? })`. Neither carries DDL. + */ +export function table( + name: Name, + definition: ExternalTableDefinition, +): Table +export function table< + const Name extends string, + const Columns extends ColumnDefs, + const Defaulted extends keyof Columns & string = never, + const Computed extends keyof Columns & string = never, +>(name: Name, columns: Columns, options?: TableOptions): Table +export function table(name: string, definition: any, options?: TableOptions): Table { + if (options === undefined && definition.external === true && typeof definition.columns === "object") { + return queryTable(name, definition.columns, definition.tenantColumn === undefined ? undefined : { tenantColumn: definition.tenantColumn }) + } + return queryTable(name, definition, options) +} +export { sqliteDialect, d1Dialect } from "./sqlite/dialect" + +/** The shared compiler, defaulting to SQLite. Pass d1Dialect for D1's limits. */ +export const compile: typeof compileCH = ( + query: Parameters[0], + params?: Record, + options?: Parameters[2], +) => compiled(() => compileCHRaw(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect })) + +/** Compile immediately, throwing on a failed compile. */ +export const compileUnsafe: typeof compileCHUnsafe = ( + query: Parameters[0], + params?: Record, + options?: Parameters[2], +) => compiledUnsafe(() => compileCHRaw(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect })) + +export const compileUnion: typeof compileUnionCH = (query, params, options) => + compileUnionCH(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect }) +export const compileUnionUnsafe: typeof compileUnionUnsafeCH = (query, params, options) => + compileUnionUnsafeCH(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect }) diff --git a/src/sqlite/d1.test.ts b/src/sqlite/d1.test.ts new file mode 100644 index 0000000..ff9c123 --- /dev/null +++ b/src/sqlite/d1.test.ts @@ -0,0 +1,88 @@ +// A real local Workers D1 binding; no cloud account or network database needed. +import * as D1Client from "@effect/sql-d1/D1Client" +import { Cause, Effect, Exit, Schema } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import { Miniflare } from "miniflare" +import { expect, it } from "vitest" +import * as Db from "../database" +import * as SQLite from "../sqlite" + +const Ack = SQLite.table("whats_new_ack", { + id: SQLite.text, + entry_id: SQLite.text, + user_id: SQLite.text, + org_id: SQLite.text, + acked_at: SQLite.text, +}, { tenantColumn: "org_id" }) +const first = { id: "ack_1", entry_id: "release_1", user_id: "user_1", org_id: "org_1", acked_at: "2026-10-01T09:00:00.000Z" } +const second = { ...first, id: "ack_2", entry_id: "release_2" } +const other = { ...first, id: "ack_3", user_id: "user_2", org_id: "org_2" } + +const withD1 = async (body: (db: Db.DatabaseApi, statements: Array) => Effect.Effect) => { + const mf = new Miniflare({ + modules: true, + script: "export default { fetch() { return new Response('D1 dialect test') } }", + compatibilityDate: "2026-07-01", + d1Databases: ["DB"], + }) + try { + const binding = await mf.getD1Database("DB") + return await Effect.runPromise(Effect.gen(function* () { + const client = yield* SqlClient.SqlClient + const statements: Array = [] + const db = Db.fromSqlClient(client, { + dialect: SQLite.d1Dialect, + requireTenantScope: true, + observe: (statement) => Effect.sync(() => void statements.push(statement.sql)), + }) + yield* db.execute({ sql: "CREATE TABLE whats_new_ack (id TEXT PRIMARY KEY, entry_id TEXT NOT NULL, user_id TEXT NOT NULL, org_id TEXT NOT NULL, acked_at TEXT NOT NULL, UNIQUE(entry_id, user_id))" }) + statements.length = 0 + return yield* body(db, statements) + }).pipe(Effect.provide(D1Client.layer({ db: binding })))) + } finally { + await mf.dispose() + } +} + +it("copies acknowledgments on D1, keeps the first row, scopes reads and pages explicitly across tenants", async () => { + const result = await withD1((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Ack).values([first, second, other]).onConflictDoNothing().crossTenant()) + yield* db.run(SQLite.insertInto(Ack).values({ ...first, id: "ack_9", acked_at: "2026-10-09T00:00:00.000Z" }).onConflictDoNothing()) + const byUser = yield* db.run(SQLite.from(Ack).select("entry_id").where(($) => [$.org_id.eq("org_1"), $.user_id.eq("user_1")]).orderBy(["entry_id", "asc"])) + const page = yield* db.run(SQLite.from(Ack).select().where(($) => [$.id.gt("ack_1")]).orderBy(["id", "asc"]).limit(2).crossTenant()) + const kept = yield* db.run(SQLite.from(Ack).select().where(($) => [$.org_id.eq("org_1"), $.id.eq("ack_1")])) + return { byUser, page, kept } + })) + expect(result).toEqual({ byUser: [{ entry_id: "release_1" }, { entry_id: "release_2" }], page: [second, other], kept: [first] }) +}, 30_000) + +it("refuses an unscoped query, too many bindings and interactive transactions before dispatch", async () => { + const seen = await withD1((db, statements) => Effect.gen(function* () { + const scope = yield* Effect.exit(db.run(SQLite.from(Ack).select())) + const limits = yield* Effect.exit(db.run(SQLite.insertInto(Ack).values(Array.from({ length: 21 }, (_, i) => ({ ...first, id: `${i}` }))).crossTenant())) + let bodyRan = false + const transaction = yield* Effect.exit(db.transaction(Effect.sync(() => { bodyRan = true }))) + return { scope, limits, transaction, bodyRan, statements: [...statements] } + })) + if (!Exit.isFailure(seen.scope) || !Exit.isFailure(seen.limits) || !Exit.isFailure(seen.transaction)) throw new Error("a D1 guard accepted an invalid request") + expect(Cause.findDefect(seen.scope.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/TenantScopeViolation" } }) + expect(Cause.findError(seen.limits.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/QueryBuilderError" } }) + expect(Cause.findError(seen.transaction.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/TransactionUnsupported" } }) + expect(seen.bodyRan).toBe(false) + expect(seen.statements).toEqual([]) +}, 30_000) + +it("decodes D1 JSON and blob wire values and returns typed errors for malformed rows", async () => { + const Payloads = SQLite.table("payloads", { id: SQLite.integer, payload: SQLite.json(Schema.Struct({ ok: Schema.Boolean })), bytes: SQLite.blob }) + const seen = await withD1((db) => Effect.gen(function* () { + yield* db.execute({ sql: "CREATE TABLE payloads (id INTEGER, payload TEXT, bytes BLOB)" }) + yield* db.run(SQLite.insertInto(Payloads).values({ id: 1, payload: { ok: true }, bytes: new Uint8Array([0, 127, 255]) })) + const rows = yield* db.run(SQLite.from(Payloads).select()) + yield* db.execute({ sql: "UPDATE payloads SET payload = '{}' WHERE id = 1" }) + const malformed = yield* Effect.exit(db.run(SQLite.from(Payloads).select())) + return { rows, malformed } + })) + expect(seen.rows).toEqual([{ id: 1, payload: { ok: true }, bytes: new Uint8Array([0, 127, 255]) }]) + if (!Exit.isFailure(seen.malformed)) throw new Error("malformed JSON was accepted") + expect(Cause.findError(seen.malformed.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/CompiledQueryDecodeError" } }) +}, 30_000) diff --git a/src/sqlite/dialect.ts b/src/sqlite/dialect.ts new file mode 100644 index 0000000..748e507 --- /dev/null +++ b/src/sqlite/dialect.ts @@ -0,0 +1,70 @@ +import { DateTime } from "effect" +import { noTransactions, type Dialect } from "../ch/dialect" +import { QueryBuilderError } from "../ch/errors" +import { fail } from "../ch/failure" +import { PARAM_MARKER_PREFIX } from "../ch/param" +import { PgTimestampLiteral, timestampLiteral } from "../pg/types" + +const hex = (value: Uint8Array): string => + Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("") + +/** Hex text keeps NUL and the compiler's reserved param marker out of SQL text. */ +const quoteString = (value: string): string => + value.includes(PARAM_MARKER_PREFIX) || value.includes("\0") + ? `CAST(X'${hex(new TextEncoder().encode(value))}' AS TEXT)` + : `'${value.replace(/'/g, "''")}'` + +const literal = (value: unknown, context: string): string => { + if (value === null) return "NULL" + if (typeof value === "string") return quoteString(value) + if (typeof value === "boolean") return value ? "1" : "0" + if (typeof value === "number" && Number.isFinite(value)) return String(value) + if (value instanceof Uint8Array) return `X'${hex(value)}'` + return fail(new QueryBuilderError({ code: "InvalidLiteral", message: `${context}: cannot write this value as a SQLite literal` }), "NULL") +} + +/** SQLite with positional bindings and transactions supplied by the caller's SqlClient. */ +export const sqliteDialect: Dialect = { + name: "sqlite", + functions: "sqlite", + ilike: false, + quoteIdent: (name) => `"${name.replace(/"/g, '""')}"`, + quoteString, + literal, + dateTimeLiteral: (value) => quoteString(DateTime.formatIso(value)), + params: { _tag: "bind", placeholder: () => "?", reuse: false, maxParameters: 32766 }, + clauses: { + format: false, + derivedTableAlias: true, + groupByAlias: false, + parenthesizeUnionBranches: false, + wrapUnionBranches: true, + offsetRequiresLimit: true, + returning: true, + onConflict: true, + onConflictConstraint: false, + insertSelectNeedsWhere: true, + defaultInValues: false, + distinctOn: false, + }, + paramCodecs: { + dateTime: PgTimestampLiteral, + dateTimeSeconds: timestampLiteral((millis) => new Date(Math.floor(millis / 1000) * 1000).toISOString()), + }, + transactions: { + support: "full", + savepoints: true, + isolationLevels: ["serializable"], + accessModes: false, + deferrable: false, + setTransaction: () => undefined, + }, +} + +/** D1 executes SQLite SQL, limits each statement to 100 bindings, and has no interactive transactions. */ +export const d1Dialect: Dialect = { + ...sqliteDialect, + name: "d1", + params: { ...sqliteDialect.params, _tag: "bind", placeholder: () => "?", reuse: false, maxParameters: 100 }, + transactions: noTransactions, +} diff --git a/src/sqlite/functions.ts b/src/sqlite/functions.ts new file mode 100644 index 0000000..afcc9b6 --- /dev/null +++ b/src/sqlite/functions.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import { makeExpr, type Condition, type Expr } from "../ch/expr" +import { schemaOf } from "../ch/define-fn" +import { builtins } from "../ch/functions/builtin" +import { compile } from "../sql/sql-fragment" + +const aggregate = builtins("sqlite", "aggregate") +const scalar = builtins("sqlite", "scalar") +const sql = (expr: Expr | Condition): string => compile(expr.toFragment()) +const nullableNumber = Schema.NullOr(Schema.Finite) + +export const count = (): Expr => makeExpr(aggregate.lazy(() => "count(*)", "count"), Schema.Int) +export const countDistinct =

(expr: Expr): Expr => + makeExpr(aggregate.lazy(() => `count(DISTINCT ${sql(expr)})`, "count"), Schema.Int) +export const countIf =

(condition: Condition

): Expr => + makeExpr(aggregate.lazy(() => `count(*) FILTER (WHERE ${sql(condition)})`, "count"), Schema.Int) +export const sum =

(expr: Expr): Expr => + makeExpr(aggregate.lazy(() => `sum(${sql(expr)})`, "sum"), nullableNumber) +export const avg =

(expr: Expr): Expr => + makeExpr(aggregate.lazy(() => `avg(${sql(expr)})`, "avg"), nullableNumber) +export const min = (expr: Expr): Expr => { + const schema = schemaOf(expr) + return makeExpr(aggregate.lazy(() => `min(${sql(expr)})`, "min"), schema === undefined ? undefined : Schema.NullOr(schema)) +} +export const max = (expr: Expr): Expr => { + const schema = schemaOf(expr) + return makeExpr(aggregate.lazy(() => `max(${sql(expr)})`, "max"), schema === undefined ? undefined : Schema.NullOr(schema)) +} +export const lower =

(expr: Expr): Expr => + makeExpr(scalar.lazy(() => `lower(${sql(expr)})`, "lower"), Schema.NullOr(Schema.String)) +export const upper =

(expr: Expr): Expr => + makeExpr(scalar.lazy(() => `upper(${sql(expr)})`, "upper"), Schema.NullOr(Schema.String)) +/** Text length in SQLite characters, or blob length in bytes. */ +export const length =

(expr: Expr): Expr => + makeExpr(scalar.lazy(() => `length(${sql(expr)})`, "length"), Schema.NullOr(Schema.Int)) +export const coalesce = (first: Expr, fallback: Expr): Expr => + makeExpr(scalar.lazy(() => `coalesce(${sql(first)}, ${sql(fallback)})`, "coalesce"), schemaOf(fallback)) diff --git a/src/sqlite/sqlite.test-d.ts b/src/sqlite/sqlite.test-d.ts new file mode 100644 index 0000000..7d3f78a --- /dev/null +++ b/src/sqlite/sqlite.test-d.ts @@ -0,0 +1,22 @@ +import type { Effect } from "effect" +import { expectTypeOf } from "expect-type" +import * as SQLite from "../sqlite" + +const Items = SQLite.table("items", { id: SQLite.integer, note: SQLite.text }) +const query = SQLite.from(Items).select().where(($) => [$.id.eq(SQLite.param.int("id"))]) +const compiled = SQLite.compileUnsafe(query, { id: 1 }) +expectTypeOf>>().toEqualTypeOf>() +const effect = SQLite.compile(query, { id: 1 }) +expectTypeOf>().toEqualTypeOf() +const insert = SQLite.compileUnsafe(SQLite.insertInto(Items).values({ id: 1, note: "n" }).returning("note")) +expectTypeOf>>().toEqualTypeOf>() +// @ts-expect-error a select cannot name a missing column +SQLite.from(Items).select("missing") +// @ts-expect-error required params remain required through the SQLite compile wrapper +SQLite.compile(query, {}) +// @ts-expect-error an integer column cannot take a string +SQLite.insertInto(Items).values({ id: "wrong", note: "n" }) +const External = SQLite.table("ext", { external: true, columns: { id: SQLite.integer } }) +SQLite.from(External).select("id") +// @ts-expect-error an external table has only its declared columns +SQLite.from(External).select("columns") diff --git a/src/sqlite/sqlite.test.ts b/src/sqlite/sqlite.test.ts new file mode 100644 index 0000000..eef78c0 --- /dev/null +++ b/src/sqlite/sqlite.test.ts @@ -0,0 +1,155 @@ +import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient" +import { Cause, DateTime, Effect, Exit, Schema } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import { expect, it } from "vitest" +import * as Db from "../database" +import * as SQLite from "../sqlite" + +const Items = SQLite.table("items", { + id: SQLite.integer, + note: SQLite.text, + enabled: SQLite.boolean, + score: SQLite.nullable(SQLite.real), +}) +const withDatabase = (body: (db: Db.DatabaseApi) => Effect.Effect) => + Effect.runPromise(Effect.gen(function* () { + const db = Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.sqliteDialect }) + yield* db.execute({ sql: "CREATE TABLE items (id INTEGER PRIMARY KEY, note TEXT NOT NULL, enabled INTEGER NOT NULL, score REAL DEFAULT 7)" }) + return yield* body(db) + }).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" })))) + +it("runs and decodes SQLite writes and selects through an existing Effect SqlClient", async () => { + const seen = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "O'Reilly\\notes", enabled: true, score: null })) + return yield* db.run(SQLite.from(Items).select().where(($) => [$.enabled.eq(SQLite.param.bool("enabled"))]), { enabled: true }) + })) + expect(seen).toEqual([{ id: 1, note: "O'Reilly\\notes", enabled: true, score: null }]) +}) + +it("keeps query and union pagination when OFFSET has no LIMIT and branches have local limits", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values([1, 2, 3].map((id) => ({ id, note: `${id}`, enabled: false, score: null })))) + const offset = yield* db.run(SQLite.from(Items).select("id").orderBy(["id", "asc"]).offset(1)) + const branch = (direction: "asc" | "desc") => SQLite.from(Items).select("id").orderBy(["id", direction]).limit(1) + const union = yield* db.run(SQLite.unionAll(branch("asc"), branch("desc")).orderBy(["id", "asc"]).offset(1)) + return { offset, union } + })) + expect(rows).toEqual({ offset: [{ id: 2 }, { id: 3 }], union: [{ id: 3 }] }) +}) + +it("refuses unsupported SQLite syntax while compiling, before sending SQL", () => { + const defaulted = SQLite.table("items", { id: SQLite.integer, note: SQLite.text }, { defaults: ["note"] }) + expect(() => SQLite.compileUnsafe(SQLite.insertInto(defaulted).values([{ id: 1, note: "a" }, { id: 2 }]))).toThrow(/DEFAULT/) + expect(() => SQLite.compileUnsafe(SQLite.insertInto(Items) + .values({ id: 1, note: "a", enabled: true, score: null }) + .onConflictDoNothing({ target: { constraint: "items_pkey" } }))).toThrow(/ON CONSTRAINT/) + expect(() => SQLite.compileUnsafe(SQLite.from(Items).select("id").distinctOn("id"))).toThrow(/DISTINCT ON/) + expect(() => SQLite.compileUnsafe(SQLite.from(Items).select("id").where(($) => [$.note.ilike("a%")]))).toThrow(/ILIKE/) + expect(() => SQLite.compileUnsafe(SQLite.from(Items).select("id").forUpdate())).toThrow(/row locks/) +}) + +it("runs INSERT SELECT with ON CONFLICT even when the source has no WHERE", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "first", enabled: true, score: null })) + yield* db.run(SQLite.insertInto(Items).select(SQLite.from(Items).select()).onConflictDoNothing()) + return yield* db.run(SQLite.from(Items).select("id")) + })) + expect(rows).toEqual([{ id: 1 }]) +}) + +it("decodes SQLite aggregates, including SQL NULL for an empty aggregate", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values([ + { id: 1, note: "A", enabled: true, score: 2 }, + { id: 2, note: "B", enabled: false, score: 4 }, + ])) + const totals = yield* db.run(SQLite.from(Items).select(($) => ({ n: SQLite.count(), enabled: SQLite.countIf($.enabled.eq(true)), total: SQLite.sum($.score), avg: SQLite.avg($.score), min: SQLite.min($.score), max: SQLite.max($.score) }))) + const empty = yield* db.run(SQLite.from(Items).select(($) => ({ n: SQLite.count(), sum: SQLite.sum($.score) })).where(($) => [$.id.eq(99)])) + return { totals, empty } + })) + expect(rows).toEqual({ totals: [{ n: 2, enabled: 1, total: 6, avg: 3, min: 2, max: 4 }], empty: [{ n: 0, sum: null }] }) +}) + +it("round-trips JSON, blobs and zoned timestamps, and runs SQLite scalar functions", async () => { + const Payloads = SQLite.table("payloads", { id: SQLite.integer, value: SQLite.json(Schema.Struct({ message: Schema.String })), bytes: SQLite.blob, at: SQLite.timestamp }) + const seen = await withDatabase((db) => Effect.gen(function* () { + yield* db.execute({ sql: "CREATE TABLE payloads (id INTEGER, value TEXT, bytes BLOB, at TEXT)" }) + const returned = yield* db.run(SQLite.insertInto(Payloads).values({ id: 1, value: { message: "__PARAM_id__\0😀" }, bytes: new Uint8Array([0, 127, 255]), at: DateTime.makeUnsafe("2026-10-01T10:00:00+02:00") }).returning()) + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "HELLO", enabled: true, score: null })) + const scalar = yield* db.run(SQLite.from(Items).select(($) => ({ lower: SQLite.lower($.note), upper: SQLite.upper($.note), length: SQLite.length($.note), fallback: SQLite.coalesce($.score, SQLite.lit(9)) }))) + return { returned, scalar } + })) + expect(seen.returned[0]?.value).toEqual({ message: "__PARAM_id__\0😀" }) + expect(seen.returned[0]?.bytes).toEqual(new Uint8Array([0, 127, 255])) + expect(seen.returned.map((row) => DateTime.formatIso(row.at))).toEqual(["2026-10-01T08:00:00.000Z"]) + expect(seen.scalar).toEqual([{ lower: "hello", upper: "HELLO", length: 5, fallback: 9 }]) +}) + +it("binds repeated positional params independently and quotes reserved-marker/NUL literals safely", async () => { + const result = await withDatabase((db) => Effect.gen(function* () { + const note = "' OR 1=1 -- __PARAM_note__\0😀" + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note, enabled: false, score: null })) + const query = SQLite.from(Items).select("id").where(($) => [$.note.eq(SQLite.param.string("note")), $.note.eq(SQLite.param.string("note"))]) + const compiled = yield* SQLite.compile(query, { note }) + const rows = yield* db.run(compiled) + const literal = yield* db.query({ sql: `SELECT ${SQLite.sqliteDialect.quoteString(note)} AS note` }) + return { parameters: compiled.parameters, rows, literal } + })) + expect(result.parameters).toEqual(["' OR 1=1 -- __PARAM_note__\0😀", "' OR 1=1 -- __PARAM_note__\0😀"]) + expect(result.rows).toEqual([{ id: 1 }]) + expect(result.literal).toEqual([{ note: "' OR 1=1 -- __PARAM_note__\0😀" }]) +}) + +it("rejects a stored value that does not match the derived row schema", async () => { + const exit = await withDatabase((db) => Effect.gen(function* () { + yield* db.execute({ sql: "INSERT INTO items VALUES (1, 'bad boolean', 2, NULL)" }) + return yield* Effect.exit(db.run(SQLite.from(Items).select())) + })) + if (!Exit.isFailure(exit)) throw new Error("invalid database row was accepted") + expect(Cause.findError(exit.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/CompiledQueryDecodeError" } }) +}) + +it("rolls back failed SQLite transactions and nested savepoints", async () => { + const ids = await withDatabase((db) => Effect.gen(function* () { + const insert = (id: number) => db.run(SQLite.insertInto(Items).values({ id, note: `${id}`, enabled: true, score: null })) + yield* db.transaction(Effect.gen(function* () { + yield* insert(1) + yield* Effect.result(db.transaction(Effect.andThen(insert(2), Effect.fail("inner")))) + yield* insert(3) + })) + yield* Effect.result(db.transaction(Effect.andThen(insert(4), Effect.fail("outer")))) + return yield* db.run(SQLite.from(Items).select("id").orderBy(["id", "asc"])) + })) + expect(ids).toEqual([{ id: 1 }, { id: 3 }]) +}) + +it("executes SQLite upserts, updates and deletes with RETURNING", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values([{ id: 1, note: "first", enabled: true, score: 2 }, { id: 2, note: "second", enabled: false, score: null }])) + const upsert = yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "replacement", enabled: true, score: 3 }) + .onConflictDoUpdate({ target: ["id"], set: { note: "updated" } }).returning("id", "note")) + const update = yield* db.run(SQLite.update(Items).set({ note: "changed" }).where(($) => [$.id.eq(2)]).returning("note")) + const deleted = yield* db.run(SQLite.deleteFrom(Items).where(($) => [$.id.eq(2)]).returning("id")) + return { upsert, update, deleted } + })) + expect(rows).toEqual({ upsert: [{ id: 1, note: "updated" }], update: [{ note: "changed" }], deleted: [{ id: 2 }] }) +}) + +it("runs scoped CTEs, joins and derived-table queries with the same row decoder", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "one", enabled: true, score: null })) + const filtered = SQLite.from(Items).select("id", "note").where(($) => [$.id.eq(SQLite.param.int("id"))]) + const Recent = SQLite.table("recent", { id: SQLite.integer, note: SQLite.text }) + const cte = yield* db.run(SQLite.from(Recent).withCTE("recent", filtered).select(), { id: 1 }) + const joined = yield* db.run(SQLite.fromQuery(filtered, "filtered").innerJoin(Items, "i", (a, b) => a.id.eq(b.id)).select(($) => ({ id: $.id, note: $.i.note })), { id: 1 }) + return { cte, joined } + })) + expect(rows).toEqual({ cte: [{ id: 1, note: "one" }], joined: [{ id: 1, note: "one" }] }) +}) + +it("builds an external table from its columns, not from the definition's keys", () => { + const External = SQLite.table("ext", { external: true, columns: { id: SQLite.integer, org_id: SQLite.text }, tenantColumn: "org_id" }) + expect(Object.keys(External.columns)).toEqual(["id", "org_id"]) + expect(External.tenantColumn).toBe("org_id") + expect(SQLite.compileUnsafe(SQLite.from(External).select("id").where(($) => [$.org_id.eq("o")])).sql).toContain('"ext"."org_id" = ?') +}) diff --git a/src/sqlite/types.ts b/src/sqlite/types.ts new file mode 100644 index 0000000..a28d9c5 --- /dev/null +++ b/src/sqlite/types.ts @@ -0,0 +1,36 @@ +import { Schema, SchemaGetter } from "effect" +import { brand, custom, type CHType } from "../ch/types" +import { PgTimestampLiteral } from "../pg/types" + +/** A SQLite storage type and the codec for its wire values. */ +export type SqliteType = CHType + +export const text = custom("text", Schema.String) +/** A JavaScript safe integer; use a custom codec for a driver's bigint mode. */ +export const integer = custom("integer", Schema.Int) +export const real = custom("real", Schema.Finite) +/** SQLite stores booleans as 0/1. Other integers are not booleans. */ +export const boolean = custom("integer", Schema.BooleanFromBit) +/** An instant stored as ISO text. */ +export const timestamp = custom("text", Schema.DateTimeUtcFromString, PgTimestampLiteral) + +const bytes = Schema.Array(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 }))).pipe( + Schema.decodeTo(Schema.Uint8Array, { + decode: SchemaGetter.transform((value) => new Uint8Array(value)), + encode: SchemaGetter.transform((value) => Array.from(value)), + }), +) +/** Node sends Uint8Array; D1 can send an array of bytes. */ +export const blob = custom("blob", Schema.Union([Schema.Uint8Array, bytes])) +/** JSON is stored as TEXT and decoded through the caller's schema. */ +export const json = (schema: Schema.Codec) => custom("text", Schema.fromJsonString(schema)) + +export const nullable = (type: SqliteType): SqliteType<"Nullable", A | null, I | null> => ({ + _tag: "Nullable", + sql: type.sql, + schema: Schema.NullOr(type.schema), + literalSchema: Schema.NullOr(type.literalSchema), + element: type, +}) + +export { brand, custom } diff --git a/tests/package-consumer-declarations.mts b/tests/package-consumer-declarations.mts index 616c47b..e4a191d 100644 --- a/tests/package-consumer-declarations.mts +++ b/tests/package-consumer-declarations.mts @@ -47,3 +47,8 @@ export const Recent = CH.table("recent", { external: true, columns: { name: CH.s export const withRecent = CH.from(Recent).withCTE("recent", eventsByApp).select(($) => ({ name: $.name })) export const fromSub = CH.fromQuery(eventsByApp, "sub").select(($) => ({ name: $.name })) export const both = CH.unionAll(eventsByApp, CH.from(Events).select(($) => ({ name: $.name }))) + +import * as SQLite from "@maple-dev/effect-orm/sqlite" +export const SqliteItems = SQLite.table("sqlite_items", { id: SQLite.integer, label: SQLite.text }) +export const sqliteById = SQLite.from(SqliteItems).select().where(($) => [$.id.eq(SQLite.param.int("id"))]) +export const sqliteCompile = () => SQLite.compile(sqliteById, { id: 1 }) diff --git a/tests/package-consumer.mts b/tests/package-consumer.mts index 5360120..6c62a7b 100644 --- a/tests/package-consumer.mts +++ b/tests/package-consumer.mts @@ -91,3 +91,14 @@ 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) + +// SQLite works from the tarball without installing any SQLite driver. +const SQLite = await import("@maple-dev/effect-orm/sqlite") +const sqliteRows = SQLite.table("consumer_rows", { id: SQLite.integer, note: SQLite.text }) +const sqliteQuery = SQLite.from(sqliteRows).select().where(($) => [$.id.eq(SQLite.param.int("id"))]) +const sqliteCompiled = await Effect.runPromise(SQLite.compile(sqliteQuery, { id: 1 })) +assert.deepEqual(sqliteCompiled.parameters, [1]) +assert.equal(sqliteCompiled.dialect, "sqlite") +const sqliteDecoded: ReadonlyArray<{ readonly id: number; readonly note: string }> = await Effect.runPromise(sqliteCompiled.decodeRows([{ id: 1, note: "sqlite" }])) +assert.deepEqual(sqliteDecoded, [{ id: 1, note: "sqlite" }]) +assert.equal(SQLite.compileUnsafe(sqliteQuery, { id: 1 }, { dialect: SQLite.d1Dialect }).dialect, "d1") diff --git a/tsdown.config.ts b/tsdown.config.ts index 7e1f75c..0815e4c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ expr: "./src/expr.ts", sql: "./src/sql/index.ts", postgres: "./src/postgres.ts", + sqlite: "./src/sqlite.ts", schema: "./src/schema.ts", tinybird: "./src/tinybird.ts", migrate: "./src/migrate.ts",