diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index 7e05d5b9..80e2e4dc 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -1,5 +1,6 @@
{"id":"effect-qb-dn3.1","title":"P0 migrate Effect dependencies and schema API surface","description":"Smallest coherent implementation slice. Align workspace/package dependencies to effect@4.0.0-beta.66 and matching v4 packages, remove @effect/experimental where replaced, and perform the mechanical Schema API migration called out in docs/effect-v4-opportunities.md without intentional behavior changes.","acceptance_criteria":"Effect package versions are aligned to the observed v4 beta line; v3 Schema call shapes listed in the opportunity doc are migrated; README/type-test examples are updated as needed; behavior tests covering risky runtime schema/json paths still pass; bun run test:types, bun test, and relevant tsgo checks are run or blockers documented.","notes":"Status: in_progress. Starting with repo inspection and focused regression/type coverage around current Schema API and JSON path inference before the mechanical v4 dependency/API migration.\nBaseline before migration: focused Bun behavior tests passed for runtime-value/json/table/executor; tsgo type-test project passed. Commands: bun --config ./bunfig.test.toml test test/internal/behavior/runtime-value.behavior.test.ts test/public/behavior/json.behavior.test.ts test/internal/behavior/table.test.ts test/public/behavior/executor.behavior.test.ts; bunx tsgo -p tsconfig.type-tests.json.\nImplemented and verified in the first migration slice. Dependencies aligned to effect@4.0.0-beta.66 and matching v4 driver/platform packages; removed @effect/experimental, @effect/sql, @effect/cli, and @effect/platform where v4 unstable exports replaced them; migrated mechanical Schema API changes and README/type examples. Checks passed: bunx tsgo -p tsconfig.type-tests.json; bun --config ./bunfig.test.toml test test/internal/behavior/runtime-value.behavior.test.ts test/public/behavior/json.behavior.test.ts test/internal/behavior/table.test.ts test/public/behavior/executor.behavior.test.ts test/public/behavior/mysql-errors.behavior.test.ts test/public/behavior/postgres-errors.behavior.test.ts; bun run test:types; bun run build; bun run test:pack; bun run test.","status":"closed","priority":0,"issue_type":"task","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:18:18Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:41:33Z","started_at":"2026-05-13T02:20:14Z","closed_at":"2026-05-13T02:41:33Z","close_reason":"P0 dependency and mechanical Schema API migration implemented; full type, build, pack, focused behavior, and full Bun tests passed.","labels":["effect-v4","migration","schema"],"dependencies":[{"issue_id":"effect-qb-dn3.1","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:18:18Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":0,"dependent_count":6,"comment_count":0}
{"id":"effect-qb-dn3","title":"Effect v4 migration epic","description":"Parent epic for the Effect v4 migration work described in docs/effect-v4-opportunities.md. Scope includes dependency alignment, mechanical Schema API migration, VariantSchema replacement, v4 SQL core adoption, SqlSchema validation, optional v4 codecs, row decode error improvements, JSON schema inference review, and CLI/platform packaging.","acceptance_criteria":"All child beads map to docs/effect-v4-opportunities.md opportunities; every opportunity is implemented, explicitly deferred, or blocked with a concrete reason; relevant tests/type checks/lint checks pass; coherent increments are committed.","notes":"Source of truth: docs/effect-v4-opportunities.md checked against effect@4.0.0-beta.66. Assumption: this branch is the v4 line, so no Effect 3 compatibility shim is required unless a child bead discovers a concrete compatibility requirement.","status":"open","priority":0,"issue_type":"epic","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:18:02Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:18:02Z","labels":["effect-v4","migration"],"dependency_count":0,"dependent_count":0,"comment_count":0}
+{"id":"effect-qb-2ur","title":"Harden Node-native verification and release v0.21.0","description":"Close the remaining verification gaps for the Node-native effect-db CLI, merge the completed branch into main, and publish the next stable release through the repository workflows.","acceptance_criteria":"Live PostgreSQL CLI integration runs on Node 22 and 24 in CI; packed installed effect-db exercises a real database command without Bun available to the CLI; fatal CLI failures have an explicit stderr contract; portable Effect Platform workflows have focused injected-service coverage for remaining write/discovery paths; all local and remote gates pass; changes are merged to main; v0.21.0 is published to npm and GitHub with verified artifacts.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-23T00:38:49Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-23T00:38:58Z","started_at":"2026-07-23T00:38:58Z","labels":["ci","database","node","release"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-xzx","title":"Release v0.20.0","description":"Publish the verified Effect v4 beta.98 code as the next stable zerover release for effect-qb and effect-db, keeping Effect dependencies pinned to 4.0.0-beta.98 and promoting npm latest to 0.20.0.","acceptance_criteria":"Release notes cover the complete stable delta since v0.19.0; package and CLI versions are 0.20.0 while Effect dependencies remain 4.0.0-beta.98; build, unit, type, pack, and integration gates pass; v0.20.0 publishes both packages under latest with signed provenance; GitHub marks v0.20.0 as Latest; release evidence is recorded and the issue is closed.","status":"closed","priority":1,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-13T19:53:35Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-13T20:00:09Z","started_at":"2026-07-13T19:53:40Z","closed_at":"2026-07-13T20:00:09Z","close_reason":"Published effect-qb and effect-db 0.20.0 via v0.20.0 after PR #9; publish run 29280566140 succeeded; npm latest tags, matching tarball shasums, signed provenance, GitHub Latest metadata, and a clean registry consumer smoke were verified.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-vt7","title":"Classify beta GitHub releases as prereleases","description":"The publish workflow maps prerelease versions to the npm beta dist-tag but unconditionally creates a normal GitHub release, causing beta versions to become Latest. Derive GitHub release classification from the same npm-tag decision and correct v4.0.0-beta.98 metadata.","acceptance_criteria":"Publish workflow passes prerelease=true and latest=false for hyphenated versions, stable versions remain latest releases, actionlint passes, and v4.0.0-beta.98 is shown as a GitHub prerelease rather than Latest.","status":"closed","priority":1,"issue_type":"bug","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-13T19:47:12Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-13T19:50:37Z","started_at":"2026-07-13T19:47:17Z","closed_at":"2026-07-13T19:50:37Z","close_reason":"Publish workflow now classifies npm beta releases as GitHub prereleases and explicitly clears the make_latest pointer; actionlint passes; beta.92 and beta.98 metadata verified as prerelease with v0.17.0 restored as latest.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-lbx","title":"Release v4.0.0-beta.98","description":"Upgrade the Effect v4 dependency stack from beta.92 to beta.98, cut matching effect-qb and effect-db beta.98 packages, publish through the repository release workflows, and verify npm plus GitHub release state.","acceptance_criteria":"All Effect packages and peer dependencies resolve to 4.0.0-beta.98; build, unit, type, pack, and integration gates pass; v4.0.0-beta.98 is tagged and published for effect-qb/effect-db under the beta dist-tag; the GitHub release and publish workflow succeed.","status":"closed","priority":1,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-13T19:35:31Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-13T19:45:11Z","started_at":"2026-07-13T19:36:09Z","closed_at":"2026-07-13T19:45:11Z","close_reason":"Published effect-qb and effect-db 4.0.0-beta.98 via tag v4.0.0-beta.98; PR #6 and publish run 29279588367 succeeded; npm beta tags and signed provenance verified.","dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -26,6 +27,10 @@
{"id":"effect-qb-dn3.4","title":"P1 validate effect-db rows with SqlSchema","description":"Use v4 effect/unstable/sql/SqlSchema for high-risk effect-db result rows, starting with Postgres introspection and migration ledger reads.","acceptance_criteria":"Introspection and migration ledger row shapes are represented by internal schemas; selected unsafe typed row reads are replaced or wrapped with SqlSchema helpers; checksum/migration behavior is unchanged except for earlier shape failures; behavior/integration tests cover happy path and malformed-row failure where feasible.","notes":"Status: open. Validation plan: postgres-introspector behavior tests, postgres-migrate behavior tests, CLI integration tests if Postgres is available. Keep source-code discovery independent of SqlSchema.\nImplemented in second migration slice. Added v4 SqlSchema row decoders around Postgres catalog introspection reads and applied-migration ledger reads. Malformed catalog and ledger row tests now assert schema decode failure before metadata/migration state is built. Checks passed: bun --config ./bunfig.test.toml test test/internal/behavior/postgres-introspector.behavior.test.ts test/internal/behavior/postgres-migrate.behavior.test.ts; bunx tsgo -p tsconfig.type-tests.json; bun run test:types; bun run build; bun run test; bun run test:pack.","status":"closed","priority":1,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:18:38Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:45:33Z","started_at":"2026-05-13T02:42:58Z","closed_at":"2026-05-13T02:45:33Z","close_reason":"effect-db catalog and migration ledger row reads now decode through v4 SqlSchema with malformed-row tests and repo gates passing.","labels":["effect-db","effect-v4","migration","sqlschema"],"dependencies":[{"issue_id":"effect-qb-dn3.4","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:18:38Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-dn3.4","depends_on_id":"effect-qb-dn3.3","type":"blocks","created_at":"2026-05-12T19:20:09Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-dn3.3","title":"P1 adopt v4 SQL core","description":"Replace standalone @effect/sql imports with effect/unstable/sql imports and update built-in executor transaction/stream handling for v4 SqlClient semantics.","acceptance_criteria":"Executor imports use v4 SQL core; streamFromSqlClient no longer relies on stale global TransactionConnection behavior; transaction consumption semantics remain covered; UniqueViolation reason usage is documented or tested without deleting existing dialect-specific SQLSTATE catalogs.","notes":"Status: open. Validation plan: executor behavior tests, executor stream type tests, SQL error behavior tests, and integration tests if driver setup is available.\nImplemented in first migration slice. SQL imports now use effect/unstable/sql/*; v4 driver packages are version-aligned; streamFromSqlClient uses the client transactionService and v4 Stream APIs instead of stale TransactionConnection/mapChunks paths. Existing dialect-specific SQLSTATE catalogs were preserved; UniqueViolation v4 reason behavior remains documented in docs/effect-v4-opportunities.md. Verified by executor behavior tests, SQL error behavior tests, full tsgo type-test project, build, pack, and full Bun test script.","status":"closed","priority":1,"issue_type":"task","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:18:33Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:41:55Z","closed_at":"2026-05-13T02:41:55Z","close_reason":"v4 SQL core imports and transaction/stream handling implemented; executor and SQL error behavior tests passed.","labels":["effect-v4","migration","sql"],"dependencies":[{"issue_id":"effect-qb-dn3.3","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:18:33Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-dn3.3","depends_on_id":"effect-qb-dn3.1","type":"blocks","created_at":"2026-05-12T19:20:09Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"effect-qb-dn3.2","title":"P1 replace experimental VariantSchema","description":"Move table select/insert/update schema derivation from @effect/experimental/VariantSchema to effect/unstable/schema/VariantSchema while preserving deriveSchemas public behavior and generated declaration intent.","acceptance_criteria":"@effect/experimental is no longer needed by querybuilder; deriveSchemas still derives select, insert, and update schemas with generated/default/nullable/primary-key/brand semantics; table behavior and type tests pass; generated declarations are intentionally updated if v4 types require it.","notes":"Status: open. Depends on the mechanical Schema API migration compiling. Validation plan: table behavior tests, table type tests, README schema examples, and tsgo type-test project.\nImplemented in first migration slice by moving table schema derivation to effect/unstable/schema/VariantSchema and removing @effect/experimental from package manifests. deriveSchemas now returns v4 Schema.Decoder values for select/insert/update. Verified by table behavior, generated README type tests, packed consumer test, and full tsgo type-test project.","status":"closed","priority":1,"issue_type":"task","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:18:25Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:41:43Z","closed_at":"2026-05-13T02:41:43Z","close_reason":"VariantSchema migration implemented with @effect/experimental removed and deriveSchemas preserved under v4 tests.","labels":["effect-v4","migration","schema"],"dependencies":[{"issue_id":"effect-qb-dn3.2","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:18:25Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-dn3.2","depends_on_id":"effect-qb-dn3.1","type":"blocks","created_at":"2026-05-12T19:20:09Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
+{"id":"effect-qb-9bn","title":"Route effect-db diagnostics through Effect Logger","description":"Use Effect CLI's built-in log-level setting for effect-db operational logs while reserving Terminal output for stable command results.","acceptance_criteria":"CLI operational summaries use Effect log levels and stderr; stable plans/status remain Terminal stdout; --log-level controls emitted diagnostics without custom filtering; tests cover stdout/stderr and suppression; Node package smoke remains green.","notes":"Implemented Effect Logger-based operational summaries with info/warning/debug levels and stderr routing; Terminal remains the stable stdout boundary for plans and status. Effect CLI's built-in --log-level controls diagnostics, including debug config context and none suppression. Verified 536 behavior tests, 55 live integration tests, tsgo types, build, and packed Node 22/24 smoke.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-22T20:40:41Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-22T20:47:45Z","started_at":"2026-07-22T20:40:49Z","closed_at":"2026-07-22T20:47:45Z","close_reason":"effect-db operational diagnostics now use Effect Logger with built-in log-level control and stable Terminal stdout; all acceptance gates pass.","labels":["database","effect-cli","logging"],"dependency_count":0,"dependent_count":0,"comment_count":0}
+{"id":"effect-qb-8l5","title":"Move effect-db I/O behind Effect Platform services","description":"The published effect-db Promise APIs still access node:fs, node:path, and node:crypto directly in config loading, source discovery, pull, and migration code. Introduce one authoritative Effect Platform boundary using FileSystem, Path, and Crypto, then migrate the CLI as the representative production caller without duplicating behavior.","acceptance_criteria":"The CLI obtains FileSystem, Path, and Crypto from NodeServices; portable core workflows contain no direct node:fs, node:path, or node:crypto access; existing Promise-facing exports remain compatible or follow an explicitly approved breaking transition; focused tests use test platform services; Node 22/24 package smoke remains green.","notes":"Implemented Effect variants for config loading, source discovery, pull planning/application, schema planning, and migration filesystem workflows. Promise exports remain compatible through one NodeServices adapter; the CLI composes Effect workflows and provides NodeServices once at the root. Source runtime code has no direct node:fs, node:path, or node:crypto imports. tinyglobby remains for discovery because Effect FileSystem.glob does not expose the existing dot-file and symlink-following semantics. Added injected FileSystem, Path, and Crypto tests. Verified 536 behavior tests, 55 live integration tests, full tsgo types, build, and packed smoke on Node 22 and 24.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-22T19:50:07Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-22T20:27:07Z","started_at":"2026-07-22T20:12:55Z","closed_at":"2026-07-22T20:27:07Z","close_reason":"effect-db filesystem, path, and crypto workflows now use Effect Platform services with compatible Node Promise adapters and NodeServices CLI assembly; all acceptance gates pass.","labels":["database","effect-platform","portability"],"dependencies":[{"issue_id":"effect-qb-8l5","depends_on_id":"effect-qb-02d","type":"discovered-from","created_at":"2026-07-22T12:50:07Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
+{"id":"effect-qb-02d","title":"Complete Node-native package verification","description":"Finish the Node-native boundaries for effect-db and effect-qb while retaining Bun for workspace package management and tooling.","acceptance_criteria":"Postgres CLI integration executes packages/database/dist/cli.js with Node; CLI host output uses Effect Platform services with Node implementations confined to runtime assembly; production package source typechecks with explicit Node types and without Bun ambient types; packed artifacts are smoked on Node 22 and Node 24 in CI; the root workspace is private and declares its Bun package manager/engine; focused and existing quality gates pass.","notes":"User clarified that portable host capabilities should use Effect Platform wherever practical; Node remains the executable adapter through NodeServices/NodeRuntime.\nImplemented: built CLI integration now runs dist/cli.js with Node; CLI output uses Effect Terminal; production sources typecheck with @types/node and no Bun globals; CI smokes packed packages on Node 22/24; root declares Bun package metadata. Production-path verification also fixed stale extension-only pull generation, Postgres bigint migration ID decoding, and the silent no-op Bun test config argument. Verified 534 behavior tests, 55 live integration tests, full type tests, build, frozen install, and packed Node 22/24 smoke.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-22T19:43:50Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-22T20:08:18Z","started_at":"2026-07-22T19:43:55Z","closed_at":"2026-07-22T20:08:18Z","close_reason":"Node-native package verification and production CLI coverage are implemented and passing; broader Effect Platform I/O migration remains tracked by effect-qb-8l5.","labels":["ci","node","packaging"],"dependency_count":0,"dependent_count":0,"comment_count":0}
+{"id":"effect-qb-tmy","title":"Run effect-db CLI on Node.js","description":"The published effectdb binary currently requires Bun through its shebang and platform runtime even though the package already supports Node.js. Replace the CLI runtime with Effect Node services while keeping Bun for repository build and test tooling.","acceptance_criteria":"A packed effect-db CLI starts under Node.js without Bun on PATH, retains CLI help behavior, and package metadata no longer requires Bun for consumers.","status":"closed","priority":2,"issue_type":"bug","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-22T03:13:09Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-22T03:22:13Z","started_at":"2026-07-22T03:13:13Z","closed_at":"2026-07-22T03:22:13Z","close_reason":"effect-db now uses the Effect Node runtime and Node-compatible file APIs; packed Node 22 smoke and behavior/type tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-1ib","title":"Merge effect v4 branch into main","description":"Bring refactor/effect-v4 up to date with current main changes, ensure published package dependency metadata treats effect as a peer dependency, run quality gates, then merge and push main.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-06-29T02:39:26Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-06-29T03:00:19Z","started_at":"2026-06-29T02:39:47Z","closed_at":"2026-06-29T03:00:19Z","close_reason":"Merged refactor/effect-v4 into main; local type, build, test, and pack gates pass. Docker integration was attempted but local Docker hung before tests could complete.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-9cp","title":"Document scoped executor transactions","description":"README transactions section should lead with the preferred Executor.withTransaction pipe-based scope and clarify that nested withTransaction calls use savepoints, while Query.transaction/savepoint remain low-level statement builders.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-06-29T02:32:14Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-06-29T02:33:38Z","started_at":"2026-06-29T02:32:39Z","closed_at":"2026-06-29T02:33:38Z","close_reason":"Added pipe-based Executor.withTransaction documentation and regenerated README type fixtures.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-px9","title":"Remove executor withSavepoint helper","description":"Public executor transaction composition should use withTransaction only. Nested withTransaction calls should represent savepoint scopes through the underlying SqlClient transaction implementation, so the explicit withSavepoint helper should be removed from exports, tests, and docs.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-06-29T02:00:45Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-06-29T02:02:41Z","started_at":"2026-06-29T02:00:53Z","closed_at":"2026-06-29T02:02:41Z","close_reason":"Removed the public withSavepoint helper and updated tests/types to use nested withTransaction for savepoint scopes.","dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -51,6 +56,8 @@
{"id":"effect-qb-dn3.7","title":"P2 harden JSON path schema inference for v4","description":"Revisit packages/querybuilder/src/internal/runtime/schema.ts manual Schema AST inspection under v4, adding focused coverage for JSON path narrowing before changing the inference logic.","acceptance_criteria":"Focused tests cover object, tuple, union, optional/missing, transformed/refined, wildcard/non-exact fallback, and JSON-compatible fallback cases; v4 AST assumptions are documented in code or bead notes; public helpers are preferred where available without weakening SQL semantics.","notes":"Implemented. Exact JSON path schema inference now walks the original v4 Schema AST rather than SchemaAST.toType so encoded-side field transformations such as NumberFromString are preserved for JSON subvalues. Added focused runtime-schema coverage for object paths, tuple indexes, unions, optional properties, missing-path fallback, wildcard fallback, refined leaves, transformations, and json/toJson compatible-vs-non-compatible fallback. Checks passed: bun --config ./bunfig.test.toml test test/internal/behavior/runtime-schema.behavior.test.ts test/public/behavior/json.behavior.test.ts; bunx tsgo -p tsconfig.type-tests.json; bun run test:types; bun run build; bun run test; bun run test:pack.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:19:08Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:56:33Z","started_at":"2026-05-13T02:52:39Z","labels":["effect-v4","json","migration","schema"],"dependencies":[{"issue_id":"effect-qb-dn3.7","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:19:08Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-dn3.7","depends_on_id":"effect-qb-dn3.1","type":"blocks","created_at":"2026-05-12T19:20:09Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-dn3.6","title":"P2 preserve v4 Schema issues in RowDecodeError","description":"Improve row decoding to use v4 Result/issue-shaped Schema APIs instead of exception-only decode flow, preserving structured SchemaIssue detail for users.","acceptance_criteria":"RowDecodeError exposes or formats structured schema issue detail; existing normalization/schema phase behavior remains compatible; JSON nested schema failures produce stable path/message output; executor behavior tests cover decode failure details.","notes":"Implemented. RowDecodeError now preserves v4 SchemaError.message and SchemaError.issue on schema decode failures while keeping the existing cause field. decodeProjectionValue uses Schema.decodeUnknownExit and extracts SchemaError from Exit instead of relying on decodeUnknownSync exceptions. Added nested JSON decode failure coverage asserting stable projection metadata plus schemaError detail. Checks passed: bun --config ./bunfig.test.toml test test/public/behavior/executor.behavior.test.ts; bunx tsgo -p tsconfig.type-tests.json; bun run test:types; bun run build; bun run test; bun run test:pack.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:18:59Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:52:24Z","started_at":"2026-05-13T02:49:58Z","closed_at":"2026-05-13T02:52:24Z","close_reason":"RowDecodeError now carries structured v4 Schema issue details and nested JSON decode failure coverage passes with repo gates.","labels":["effect-v4","errors","executor","migration","schema"],"dependencies":[{"issue_id":"effect-qb-dn3.6","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:18:59Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-dn3.6","depends_on_id":"effect-qb-dn3.1","type":"blocks","created_at":"2026-05-12T19:20:09Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-dn3.5","title":"P2 add opt-in v4 Schema codecs","description":"Expose or document opt-in column/runtime helpers for v4 Schema codecs such as DateFromString, BigIntFromString, BigDecimalFromString, DurationFromString, and encoded string codecs without silently changing default column decoded types.","acceptance_criteria":"Default temporal/numeric/binary decoded types remain stable; opt-in recipes or helpers are available for supported codecs; invalid schema overrides still fail at type level; README/generated type snippets are updated and checked.","notes":"Status: open. Validation plan: column schema compatibility type tests, README generated type tests, runtime decode tests for at least one temporal/numeric codec.\nImplemented in third migration slice. Documented opt-in v4 codec pairings through C.schema for BigIntFromString, BigDecimalFromString, DurationFromString, and flipped Uint8ArrayFromBase64. Added type tests proving opt-in decoded types and default column types remain unchanged, plus executor behavior coverage for codec transforms after canonical normalization. Checks passed: bun --config ./bunfig.test.toml test test/public/behavior/executor.behavior.test.ts; bunx tsgo -p tsconfig.type-tests.json; bun run test:types; bun run build; bun run test; bun run test:pack.","status":"closed","priority":2,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:18:50Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:48:39Z","started_at":"2026-05-13T02:45:55Z","closed_at":"2026-05-13T02:48:39Z","close_reason":"v4 codec usage is documented and covered by type/runtime tests while default column decoded types remain unchanged.","labels":["codecs","effect-v4","migration","schema"],"dependencies":[{"issue_id":"effect-qb-dn3.5","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:18:50Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-dn3.5","depends_on_id":"effect-qb-dn3.1","type":"blocks","created_at":"2026-05-12T19:20:09Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
+{"id":"effect-qb-7h9","title":"Record esbuild runtime assumption","description":"Document that effect-qb and effect-db consumer code is expected to be bundled or built with esbuild, so runtime TypeScript loader compatibility is not a repository responsibility.","acceptance_criteria":"AGENTS.md records esbuild as the owner of TypeScript and module transformation while Bun remains the workspace package manager.","status":"closed","priority":3,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-22T19:40:55Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-22T19:41:42Z","started_at":"2026-07-22T19:41:00Z","closed_at":"2026-07-22T19:41:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
+{"id":"effect-qb-zk7","title":"Document branch naming convention","description":"Record the repository preference that automation branches must not use the codex/ prefix.","acceptance_criteria":"AGENTS.md explicitly says not to prefix branch names with codex/, and the active branch is renamed without that prefix.","status":"closed","priority":3,"issue_type":"task","assignee":"Ramazan Elsunkaev","owner":"relsunkaev@outlook.com","created_at":"2026-07-22T19:08:08Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-07-22T19:08:24Z","started_at":"2026-07-22T19:08:12Z","closed_at":"2026-07-22T19:08:24Z","close_reason":"Recorded the no codex/ branch-prefix convention and renamed the active branch.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-yll.5","title":"Phase 5 — Consolidate portable datatypes, expose ./standard in package.json, docs","description":"# Phase 5 — Tighten\n\nDepends on Phase 4 (`src/standard/` must exist).\n\nPolish phase: move portable datatypes into `standard/`, expose the public entry point in `package.json`, and add documentation.\n\n## Substeps\n\n1. **Datatypes audit.** Walk through `src/{mysql,postgres,sqlite}/datatypes/`. Move every type that's universal SQL into `src/standard/datatypes/`. Per-dialect folders should retain only true natives: postgres ranges/`citext`/`jsonb`/array types; mysql `set`/`enum`/`mediumtext`; sqlite-specific affinity quirks. Update per-dialect `datatypes/index.ts` to re-export from `standard/datatypes/` and add the extras.\n2. **`package.json` exports.** Add `\"./standard\"` to the `exports` map in `packages/querybuilder/package.json`, mirroring the existing `\"./mysql\"`, `\"./postgres\"`, `\"./sqlite\"` shape. Verify `dist/standard/` is produced by the build.\n3. **Documentation.**\n - Add a `docs/` page explaining the new `standard` namespace, the dialect tag rule (`\"standard\"` is portable; mixing concrete dialects is a compile error), and migration tips for users who want portable queries.\n - Update `README.md` with a short \"Writing portable queries\" section using the standard namespace.\n - Add an example file under `docs/examples/` demonstrating a portable query and a postgres-narrowed query.\n4. **Build + smoke test.** Run `bun run build` per package. Verify `dist/standard/` shape mirrors `dist/postgres/` for the equivalent files. Run the README examples unchanged.\n\n## Files touched\n\n**Modified:** `packages/querybuilder/package.json`; `src/{mysql,postgres,sqlite}/datatypes/index.ts`; `README.md`; `docs/*`.\n\n**Created:** docs page for standard namespace; example file(s).\n\n**Possibly moved:** specific datatype definitions from per-dialect to `src/standard/datatypes/`.\n\n## Acceptance\n\n- `package.json` exposes `./standard` as a public entry point; `dist/standard/` builds successfully.\n- Per-dialect `datatypes/` folders contain only genuinely dialect-specific types.\n- README and docs reflect the new namespace.\n- All tests pass.\n- `import * as Std from \"@effect-qb/querybuilder/standard\"` works in a fresh project.\n","acceptance_criteria":"package.json exposes ./standard; dist/standard/ builds; per-dialect datatypes/ folders contain only genuinely dialect-specific types; README and docs include a 'Writing portable queries' section using the standard namespace; an example file demonstrates a portable query and a postgres-narrowed query; all tests pass.","notes":"Partial docs/build pieces are present: package exports include ./standard, packages/querybuilder/scripts/build.ts emits standard.js, docs/standard-namespace.md and docs/examples/standard-query.ts were added, and README/package README mention portable queries. Not acceptance-complete: portable datatypes were not audited/moved out of per-dialect folders, and docs are minimal.\n2026-05-23 update: standard column coverage now includes varchar, char, bigint, real, and blob with runtime type coverage; package build and pack smoke pass for effect-qb/standard. Remaining phase-5 work is any deeper per-dialect datatype folder audit/documentation expansion.","status":"open","priority":3,"issue_type":"task","owner":"relsunkaev@outlook.com","created_at":"2026-05-19T20:53:23Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-24T01:48:19Z","labels":["architecture","dialect","docs","phase-5","querybuilder","refactor"],"dependencies":[{"issue_id":"effect-qb-yll.5","depends_on_id":"effect-qb-yll","type":"parent-child","created_at":"2026-05-19T13:53:22Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-yll.5","depends_on_id":"effect-qb-yll.4","type":"blocks","created_at":"2026-05-19T13:53:30Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-dn3.10","title":"P3 decide schema optics follow-up","description":"Track the schema optics/lenses follow-up from docs/effect-v4-schema-optics.md so related v4 work remains under the epic.","acceptance_criteria":"Decision remains linked to the epic; Effect Optic is documented only for decoded value helpers unless a separate JsonPath.focus implementation bead is opened; no mechanical migration work depends on optics.","notes":"Status: deferred by current evaluation. docs/effect-v4-schema-optics.md verdict: useful narrowly for decoded values and API inspiration, not a replacement for schema derivation/runtime inference/SQL JSON paths.","status":"closed","priority":3,"issue_type":"decision","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:19:44Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:42:11Z","closed_at":"2026-05-13T02:42:11Z","close_reason":"Optics follow-up decision recorded in docs/effect-v4-schema-optics.md; no mechanical v4 migration work depends on optics.","labels":["effect-v4","migration","optics","schema"],"dependencies":[{"issue_id":"effect-qb-dn3.10","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:19:44Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"effect-qb-dn3.8","title":"P3 migrate effect-db CLI/platform imports","description":"Move effect-db CLI/platform usage to v4-aligned packages/imports and evaluate v4 CLI improvements for destructive confirmation flows without breaking non-interactive CI usage.","acceptance_criteria":"CLI imports are v4-aligned; existing commands still parse and run; boolean flag behavior is verified, especially destructive flags; any interactive confirmation is additive and bypassable for CI.","notes":"Status: open. Validation plan: postgres CLI behavior/integration tests and direct --help/parse smoke tests with Bun.\nImplemented in first migration slice. effect-db CLI now imports Command/Flag from effect/unstable/cli and BunRuntime/BunServices from @effect/platform-bun v4. Verified root and subcommand help paths: bun packages/database/src/cli.ts --help; bun packages/database/src/cli.ts push --help; bun packages/database/src/cli.ts migrate generate --help; bun packages/database/src/cli.ts migrate down --help. Boolean flags remain visible for non-interactive CI flows.","status":"closed","priority":3,"issue_type":"task","owner":"relsunkaev@outlook.com","created_at":"2026-05-13T02:19:21Z","created_by":"Ramazan Elsunkaev","updated_at":"2026-05-13T02:42:05Z","closed_at":"2026-05-13T02:42:05Z","close_reason":"effect-db CLI/platform imports migrated to v4 and help/flag parsing smoke tests passed.","labels":["cli","effect-db","effect-v4","migration"],"dependencies":[{"issue_id":"effect-qb-dn3.8","depends_on_id":"effect-qb-dn3","type":"parent-child","created_at":"2026-05-12T19:19:20Z","created_by":"Ramazan Elsunkaev","metadata":"{}"},{"issue_id":"effect-qb-dn3.8","depends_on_id":"effect-qb-dn3.1","type":"blocks","created_at":"2026-05-12T19:20:09Z","created_by":"Ramazan Elsunkaev","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1c6860c4..e3d1b2f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,7 +27,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.5
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -46,12 +46,17 @@ jobs:
bunx npm pack --dry-run ./packages/querybuilder
bunx npm pack --dry-run ./packages/database
- - name: Smoke test packed querybuilder package
- run: bun run test:pack
-
verify-node:
- name: Verify Node 22
+ name: ${{ matrix.name }}
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - node: 22
+ name: Verify Node 22
+ - node: 24
+ name: Verify Node 24
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -59,43 +64,37 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Build package
- run: bun run build
+ bun-version: 1.3.5
- name: Setup Node
uses: actions/setup-node@v4
with:
- node-version: 22.0.0
+ node-version: ${{ matrix.node }}
- - name: Smoke test published entrypoints
- run: |
- node --input-type=module <<'EOF'
- import * as Root from "./packages/querybuilder/dist/index.js"
- import * as Postgres from "./packages/querybuilder/dist/postgres.js"
- import * as Database from "./packages/database/dist/index.js"
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
- if (typeof Root.Query?.select !== "function") {
- throw new Error("querybuilder root entrypoint did not load")
- }
+ - name: Build package
+ run: bun run build
- if (typeof Postgres.Renderer?.make !== "function" || typeof Postgres.Executor?.make !== "function") {
- throw new Error("querybuilder postgres entrypoint did not load")
- }
+ - name: Typecheck production sources without Bun globals
+ run: bun run test:types:node
- if (typeof Database.defineConfig !== "function") {
- throw new Error("database entrypoint did not load")
- }
- EOF
+ - name: Smoke test packed packages
+ run: bun run test:pack
integration:
- name: Integration
+ name: ${{ matrix.name }}
runs-on: ubuntu-latest
needs: verify
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - node: 22
+ name: Integration
+ - node: 24
+ name: Integration Node 24
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -103,13 +102,15 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: latest
+ bun-version: 1.3.5
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
- name: Install dependencies
run: bun install --frozen-lockfile
- - name: Build package
- run: bun run build
-
- name: Run integration tests
run: bun run test:integration
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index be4a1b25..753aa38f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,6 +26,11 @@ jobs:
with:
bun-version: 1.3.5
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/AGENTS.md b/AGENTS.md
index 96a37536..f4ccc3ae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -119,6 +119,20 @@ bun --hot ./index.ts
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
+## Git
+
+- Do not prefix branch names with `codex/`.
+
+## Runtime and build boundaries
+
+- Keep Bun as the workspace package manager and tooling runtime.
+- Keep published `effect-qb` and `effect-db` runtime behavior Node.js-native.
+- Prefer Effect Platform services for filesystem, path, terminal, process, and
+ other host capabilities. Keep Node-specific implementations at the runtime
+ assembly boundary with `NodeServices` and `NodeRuntime`.
+- Assume consumer code is bundled or built with esbuild. Do not add a runtime
+ TypeScript loader solely to support unbuilt TypeScript syntax.
+
## Beads Issue Tracker
diff --git a/bun.lock b/bun.lock
index c7b5723d..0e6d1ed9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
"@effect/sql-pg": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@types/bun": "latest",
+ "@types/node": "^22.0.0",
"@typescript/native-preview": "beta",
"effect": "4.0.0-beta.98",
"effect-db": "workspace:*",
@@ -25,10 +26,10 @@
"effectdb": "./dist/cli.js",
},
"dependencies": {
- "@effect/platform-bun": "4.0.0-beta.98",
- "@effect/platform-node-shared": "4.0.0-beta.98",
+ "@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-pg": "4.0.0-beta.98",
"effect-qb": "workspace:*",
+ "tinyglobby": "0.2.17",
"typescript": "^6.0.2",
},
"peerDependencies": {
@@ -56,6 +57,8 @@
"packages": {
"@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-QorRKhvwHDPwvE8lH5lE0r0dI2z+zj12VUN4h7hfoIunKUiXSoSOMZqF3w13W30RpV6ivsvJywUycaa/18yMPA=="],
+ "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
+
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.98", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w=="],
"@effect/sql-mysql2": ["@effect/sql-mysql2@4.0.0-beta.98", "", { "dependencies": { "mysql2": "^3.22.6" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-BIBkQNJTNPjv+80H0mpm5GMTHRukLuAXZ1UCYA2O8Cwl4gysua1li3BxAlK7BEHMr8byqoIyjBhPbHrWTWi09Q=="],
@@ -64,6 +67,8 @@
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.98", "", { "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-cc41uLhYBqexdbTNu4dlui+31E8hcVLEapLySa0C8d60FmBY8IEAV/RD3oF+6pqPslKEZ9p1+XVLdDm0iflw5Q=="],
+ "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="],
+
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
@@ -80,7 +85,7 @@
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
- "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
+ "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -104,8 +109,12 @@
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+ "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
+
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -120,6 +129,8 @@
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
@@ -128,6 +139,8 @@
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
+ "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="],
+
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
@@ -136,8 +149,12 @@
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
+ "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
+
"moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="],
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
@@ -176,6 +193,8 @@
"pgsql-ast-parser": ["pgsql-ast-parser@12.0.2", "", { "dependencies": { "moo": "^0.5.1", "nearley": "^2.19.5" } }, "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw=="],
+ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
"postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="],
@@ -192,6 +211,10 @@
"randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="],
+ "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
+
+ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
+
"ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@@ -200,11 +223,17 @@
"sql-escaper": ["sql-escaper@1.5.1", "", {}, "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg=="],
+ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
"toml": ["toml@4.1.2", "", {}, "sha512-m0vXfHODcw3gk+KONAOlVQ5yNHc3yS3B1ybM3HS1vqDoS0RWTDDVBVVTYi8hH0k+2OM1vmo9fb1WX9EVqjqfHA=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
- "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
+ "undici": ["undici@8.8.0", "", {}, "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
@@ -214,8 +243,16 @@
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
+ "@types/ws/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
+
+ "bun-types/@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
+
"pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
+ "@types/ws/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
+
+ "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
+
"pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
diff --git a/package.json b/package.json
index 37cf9900..2ca20850 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,12 @@
{
"name": "effect-qb-workspace",
"version": "0.20.0",
+ "private": true,
"type": "module",
+ "packageManager": "bun@1.3.5",
+ "engines": {
+ "bun": ">=1.3.5"
+ },
"workspaces": [
"packages/*"
],
@@ -20,9 +25,10 @@
"generate:readme-types": "bun scripts/generate-readme-type-tests.ts",
"release": "bun scripts/release.ts",
"test:pack": "bun scripts/check-packed-querybuilder.ts",
- "test": "bun --config ./bunfig.test.toml test",
- "test:types": "bun run generate:readme-types && bunx tsgo -p tsconfig.type-tests.json",
- "test:integration": "bun scripts/test-integration.ts",
+ "test": "bun test",
+ "test:types": "bun run generate:readme-types && bunx tsgo -p tsconfig.type-tests.json && bun run test:types:node",
+ "test:types:node": "bunx tsgo -p tsconfig.node.json",
+ "test:integration": "bun run build && bun scripts/test-integration.ts",
"test:integration:datatypes": "bun scripts/test-datatype-matrix-integration.ts"
},
"devDependencies": {
@@ -31,6 +37,7 @@
"@effect/sql-pg": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@types/bun": "latest",
+ "@types/node": "^22.0.0",
"@typescript/native-preview": "beta",
"effect": "4.0.0-beta.98",
"effect-db": "workspace:*",
diff --git a/packages/database/README.md b/packages/database/README.md
index 27b4cdf4..677ec8f3 100644
--- a/packages/database/README.md
+++ b/packages/database/README.md
@@ -44,6 +44,19 @@ effectdb migrate generate
effectdb migrate up
```
+`effectdb` uses Effect CLI's global `--log-level` flag. Operational logs are
+written to stderr and default to `info`; use `--log-level debug` for config
+diagnostics or `--log-level none` to suppress logs. Plans and status
+output remain on stdout.
+
+## Runtime services
+
+The CLI obtains file-system, path, crypto, and terminal capabilities from
+`@effect/platform-node`. The core workflows also export `*Effect` variants that
+accept the corresponding Effect Platform services, so another runtime can
+provide its own implementations. Existing Promise APIs remain Node-backed for
+compatibility.
+
## Operational Notes
`effectdb` manages canonical table and enum declarations, not the entire Postgres catalog.
diff --git a/packages/database/package.json b/packages/database/package.json
index c9832eb4..c6995800 100644
--- a/packages/database/package.json
+++ b/packages/database/package.json
@@ -12,8 +12,7 @@
},
"homepage": "https://github.com/relsunkaev/effect-qb#readme",
"engines": {
- "node": ">=22",
- "bun": ">=1.3.5"
+ "node": ">=22"
},
"bin": {
"effectdb": "./dist/cli.js"
@@ -56,10 +55,10 @@
"prepack": "bun run build"
},
"dependencies": {
- "@effect/platform-bun": "4.0.0-beta.98",
- "@effect/platform-node-shared": "4.0.0-beta.98",
+ "@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-pg": "4.0.0-beta.98",
"effect-qb": "workspace:*",
+ "tinyglobby": "0.2.17",
"typescript": "^6.0.2"
},
"peerDependencies": {
diff --git a/packages/database/scripts/build.ts b/packages/database/scripts/build.ts
index aa0a8a28..59beb751 100644
--- a/packages/database/scripts/build.ts
+++ b/packages/database/scripts/build.ts
@@ -56,7 +56,7 @@ const main = async () => {
"--skipLibCheck",
"--allowImportingTsExtensions",
"--types",
- "bun",
+ "node",
"src/index.ts",
"src/postgres/pull.ts",
"src/postgres/push.ts",
diff --git a/packages/database/src/cli.ts b/packages/database/src/cli.ts
index 8f369ee6..22aea616 100644
--- a/packages/database/src/cli.ts
+++ b/packages/database/src/cli.ts
@@ -1,12 +1,14 @@
-#!/usr/bin/env bun
-import { BunRuntime, BunServices } from "@effect/platform-bun"
+#!/usr/bin/env node
+import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Command, Flag } from "effect/unstable/cli"
import * as Effect from "effect/Effect"
+import * as Logger from "effect/Logger"
import * as Option from "effect/Option"
+import * as Terminal from "effect/Terminal"
-import { loadPostgresConfig, resolveDatabaseUrl } from "./internal/postgres-config.js"
+import { loadPostgresConfigEffect, resolveDatabaseUrl, type EffectDbConfig } from "./internal/postgres-config.js"
import {
- loadPostgresSchemaPlan,
+ loadPostgresSchemaPlanEffect,
selectedPostgresSchemaChanges,
skippedPostgresSchemaChanges,
summarizeSelectedPostgresPlan,
@@ -17,42 +19,30 @@ import {
applyMigrationFiles,
applyStatements,
loadAppliedMigrationRows,
- loadPostgresMigrationState,
- migrationDirFromConfig,
+ loadPostgresMigrationStateEffect,
+ migrationDirFromConfigEffect,
migrationFileLabel,
- readMigrationFiles,
+ readMigrationFilesEffect,
rollbackMigrationFiles,
withMigrationLock,
- writeMigrationFile
+ writeMigrationFileEffect
} from "./postgres/migrate.js"
-import { planPostgresPull, applyPullPlan, summarizePullPlan } from "./postgres/pull.js"
-import { runPostgresUrl } from "./internal/postgres-runtime.js"
+import { planPostgresPullEffect, applyPullPlanEffect, summarizePullPlanEffect } from "./postgres/pull.js"
+import { providePostgresUrl } from "./internal/postgres-runtime.js"
import { introspectPostgresSchema } from "./internal/postgres-introspector.js"
import { filterDiscoveredSourceSchema } from "./internal/postgres-source-filter.js"
-import { discoverSourceSchema } from "./internal/postgres-source-discovery.js"
+import { discoverSourceSchemaEffect } from "./internal/postgres-source-discovery.js"
-const toError = (cause: unknown): Error =>
- cause instanceof Error
- ? cause
- : new Error(String(cause))
+const outputLines = (lines: readonly string[]) =>
+ lines.length === 0
+ ? Effect.void
+ : Effect.flatMap(Terminal.Terminal, (terminal) => terminal.display(`${lines.join("\n")}\n`))
-const effectFromPromise = (evaluate: () => Promise): Effect.Effect =>
- Effect.tryPromise({
- try: evaluate,
- catch: toError
- })
-
-const log = (line: string): Effect.Effect =>
- Effect.sync(() => {
- console.log(line)
- })
+const logInfoLines = (lines: readonly string[]) =>
+ Effect.forEach(lines, (line) => Effect.logInfo(line), { discard: true })
-const logLines = (lines: readonly string[]): Effect.Effect =>
- Effect.sync(() => {
- if (lines.length > 0) {
- console.log(lines.join("\n"))
- }
- })
+const logWarningLines = (lines: readonly string[]) =>
+ Effect.forEach(lines, (line) => Effect.logWarning(line), { discard: true })
const configOption = Flag.string("config").pipe(
Flag.optional,
@@ -83,19 +73,27 @@ const stepsOption = Flag.integer("steps").pipe(
Flag.withDescription("Number of applied migrations to roll back")
)
-const withLoadedConfig = (
+const loadConfig = (explicitConfigPath: Option.Option) =>
+ loadPostgresConfigEffect(process.cwd(), Option.getOrUndefined(explicitConfigPath)).pipe(
+ Effect.tap((loaded) => Effect.logDebug("loaded database config", {
+ cwd: loaded.cwd,
+ path: loaded.path
+ }))
+ )
+
+const withLoadedConfig = (
explicitConfigPath: Option.Option,
explicitUrl: Option.Option,
f: (args: {
readonly cwd: string
readonly configPath?: string
readonly databaseUrl: string
- readonly config: Awaited>["config"]
- }) => Promise
-): Effect.Effect =>
- effectFromPromise(async () => {
- const loaded = await loadPostgresConfig(process.cwd(), Option.getOrUndefined(explicitConfigPath))
- return await f({
+ readonly config: EffectDbConfig
+ }) => Effect.Effect
+) =>
+ Effect.gen(function*() {
+ const loaded = yield* loadConfig(explicitConfigPath)
+ return yield* f({
cwd: loaded.cwd,
configPath: loaded.path,
databaseUrl: resolveDatabaseUrl(loaded.config, Option.getOrUndefined(explicitUrl)),
@@ -113,33 +111,33 @@ const push = Command.make(
},
({ config, url, dryRun, allowDestructive }) =>
Effect.gen(function*() {
- const { plan, discovered } = yield* withLoadedConfig(config, url, async ({ cwd, config, databaseUrl }) =>
- loadPostgresSchemaPlan(cwd, config, databaseUrl)
+ const { plan, discovered } = yield* withLoadedConfig(config, url, ({ cwd, config, databaseUrl }) =>
+ loadPostgresSchemaPlanEffect(cwd, config, databaseUrl)
)
const selected = selectedPostgresSchemaChanges(plan, allowDestructive)
const skipped = skippedPostgresSchemaChanges(plan, allowDestructive)
- yield* logLines([
+ yield* outputLines([
`discovered ${discovered.model.tables.length} table(s) and ${discovered.model.enums.length} enum(s)`,
...summarizeSelectedPostgresPlan("planned changes", plan.changes)
])
if (dryRun) {
- return yield* logLines(skipped.length === 0
+ return yield* outputLines(skipped.length === 0
? []
: summarizeSelectedPostgresPlan("skipped changes", skipped))
}
if (selected.length > 0) {
yield* withLoadedConfig(config, url, ({ databaseUrl }) =>
- runPostgresUrl(
+ providePostgresUrl(
databaseUrl,
applyStatements(selected.map((change) => change.sql!).filter((sql): sql is string => sql !== undefined))
)
)
- yield* log(`applied ${selected.length} statement(s)`)
+ yield* Effect.logInfo(`applied ${selected.length} statement(s)`)
} else {
- yield* log("no executable statements selected")
+ yield* Effect.logInfo("no executable statements selected")
}
if (skipped.length > 0) {
- yield* logLines(summarizeSelectedPostgresPlan("skipped changes", skipped))
+ yield* logWarningLines(summarizeSelectedPostgresPlan("skipped changes", skipped))
}
})
)
@@ -153,29 +151,29 @@ const pull = Command.make(
},
({ config, url, dryRun }) =>
Effect.gen(function*() {
- const { loaded, database, discovered, plan } = yield* effectFromPromise(async () => {
- const loaded = await loadPostgresConfig(process.cwd(), Option.getOrUndefined(config))
+ const { loaded, database, discovered, plan } = yield* Effect.gen(function*() {
+ const loaded = yield* loadConfig(config)
const databaseUrl = resolveDatabaseUrl(loaded.config, Option.getOrUndefined(url))
const discovered = filterDiscoveredSourceSchema(
- await discoverSourceSchema(loaded.cwd, loaded.config.source),
+ yield* discoverSourceSchemaEffect(loaded.cwd, loaded.config.source),
loaded.config.filter
)
const database = withoutManagedMigrationTable(
- await runPostgresUrl(databaseUrl, introspectPostgresSchema(loaded.config.filter)),
+ yield* providePostgresUrl(databaseUrl, introspectPostgresSchema(loaded.config.filter)),
loaded.config.migrations.table
)
- const plan = await planPostgresPull(loaded.cwd, loaded.config.source, discovered, database)
+ const plan = yield* planPostgresPullEffect(loaded.cwd, loaded.config.source, discovered, database)
return { loaded, database, discovered, plan }
})
void database
void discovered
if (plan.updates.length === 0) {
- return yield* log("schema definitions are already up to date")
+ return yield* Effect.logInfo("schema definitions are already up to date")
}
- yield* logLines(summarizePullPlan(loaded.cwd, plan))
+ yield* outputLines(yield* summarizePullPlanEffect(loaded.cwd, plan))
if (!dryRun) {
- yield* effectFromPromise(() => applyPullPlan(plan))
- yield* log(`updated ${plan.updates.length} file(s)`)
+ yield* applyPullPlanEffect(plan)
+ yield* Effect.logInfo(`updated ${plan.updates.length} file(s)`)
}
})
)
@@ -190,28 +188,27 @@ const migrateGenerate = Command.make(
},
({ config, url, allowDestructive, name }) =>
Effect.gen(function*() {
- const { loaded, plan } = yield* effectFromPromise(async () => {
- const loaded = await loadPostgresConfig(process.cwd(), Option.getOrUndefined(config))
+ const { loaded, plan } = yield* Effect.gen(function*() {
+ const loaded = yield* loadConfig(config)
const databaseUrl = resolveDatabaseUrl(loaded.config, Option.getOrUndefined(url))
- const { plan } = await loadPostgresSchemaPlan(loaded.cwd, loaded.config, databaseUrl)
+ const { plan } = yield* loadPostgresSchemaPlanEffect(loaded.cwd, loaded.config, databaseUrl)
return { loaded, plan }
})
const selected = selectedPostgresSchemaChanges(plan, allowDestructive)
const skipped = skippedPostgresSchemaChanges(plan, allowDestructive)
if (selected.length === 0) {
- yield* log("no executable migration changes selected")
+ yield* Effect.logInfo("no executable migration changes selected")
} else {
- const filePath = yield* effectFromPromise(() =>
- writeMigrationFile(
- migrationDirFromConfig(loaded.cwd, loaded.config.migrations.dir),
- Option.getOrElse(name, () => allowDestructive ? "schema_destructive" : "schema_safe"),
- selected
- )
+ const migrationsDir = yield* migrationDirFromConfigEffect(loaded.cwd, loaded.config.migrations.dir)
+ const filePath = yield* writeMigrationFileEffect(
+ migrationsDir,
+ Option.getOrElse(name, () => allowDestructive ? "schema_destructive" : "schema_safe"),
+ selected
)
- yield* log(`wrote ${migrationFileLabel(filePath)}`)
+ yield* Effect.logInfo(`wrote ${migrationFileLabel(filePath)}`)
}
if (skipped.length > 0) {
- yield* logLines(summarizeSelectedPostgresPlan("skipped changes", skipped))
+ yield* logWarningLines(summarizeSelectedPostgresPlan("skipped changes", skipped))
}
})
)
@@ -224,35 +221,29 @@ const migrateUp = Command.make(
},
({ config, url }) =>
Effect.gen(function*() {
- const { loaded, databaseUrl } = yield* effectFromPromise(async (): Promise<{
- readonly loaded: Awaited>
- readonly databaseUrl: string
- }> => {
- const loaded = await loadPostgresConfig(process.cwd(), Option.getOrUndefined(config))
+ const { loaded, databaseUrl } = yield* Effect.gen(function*() {
+ const loaded = yield* loadConfig(config)
const databaseUrl = resolveDatabaseUrl(loaded.config, Option.getOrUndefined(url))
return { loaded, databaseUrl }
})
- const applied = yield* effectFromPromise(() =>
- runPostgresUrl(
- databaseUrl,
- withMigrationLock(loaded.config.migrations.table, Effect.gen(function*() {
- const files = yield* Effect.promise(() =>
- readMigrationFiles(migrationDirFromConfig(loaded.cwd, loaded.config.migrations.dir))
- )
- const appliedRows = yield* loadAppliedMigrationRows(loaded.config.migrations.table, files)
- const applied = new Set(appliedRows.map((row) => row.name))
- const currentPending = files.filter((file) => !applied.has(file.name))
- if (currentPending.length > 0) {
- yield* applyMigrationFiles(loaded.config.migrations.table, currentPending)
- }
- return currentPending
- }))
- )
+ const applied = yield* providePostgresUrl(
+ databaseUrl,
+ withMigrationLock(loaded.config.migrations.table, Effect.gen(function*() {
+ const migrationsDir = yield* migrationDirFromConfigEffect(loaded.cwd, loaded.config.migrations.dir)
+ const files = yield* readMigrationFilesEffect(migrationsDir)
+ const appliedRows = yield* loadAppliedMigrationRows(loaded.config.migrations.table, files)
+ const applied = new Set(appliedRows.map((row) => row.name))
+ const currentPending = files.filter((file) => !applied.has(file.name))
+ if (currentPending.length > 0) {
+ yield* applyMigrationFiles(loaded.config.migrations.table, currentPending)
+ }
+ return currentPending
+ }))
)
if (applied.length === 0) {
- return yield* log("no pending migrations")
+ return yield* Effect.logInfo("no pending migrations")
}
- yield* logLines([
+ yield* logInfoLines([
`applied ${applied.length} migration(s)`,
...applied.map((file) => ` - ${file.name}`)
])
@@ -267,10 +258,10 @@ const migrateStatus = Command.make(
},
({ config, url }) =>
Effect.gen(function*() {
- const { loaded, databaseUrl, appliedRows, pending } = yield* effectFromPromise(async () => {
- const loaded = await loadPostgresConfig(process.cwd(), Option.getOrUndefined(config))
+ const { loaded, databaseUrl, appliedRows, pending } = yield* Effect.gen(function*() {
+ const loaded = yield* loadConfig(config)
const databaseUrl = resolveDatabaseUrl(loaded.config, Option.getOrUndefined(url))
- const state = await loadPostgresMigrationState(loaded, databaseUrl)
+ const state = yield* loadPostgresMigrationStateEffect(loaded, databaseUrl)
return {
loaded,
databaseUrl,
@@ -280,7 +271,7 @@ const migrateStatus = Command.make(
})
void loaded
void databaseUrl
- yield* logLines([
+ yield* outputLines([
`applied migrations (${appliedRows.length}):`,
...appliedRows.map((row) => ` - ${row.name}`),
`pending migrations (${pending.length}):`,
@@ -299,10 +290,10 @@ const migrateDown = Command.make(
},
({ config, url, dryRun, steps }) =>
Effect.gen(function*() {
- const { loaded, databaseUrl, selected } = yield* effectFromPromise(async () => {
- const loaded = await loadPostgresConfig(process.cwd(), Option.getOrUndefined(config))
+ const { loaded, databaseUrl, selected } = yield* Effect.gen(function*() {
+ const loaded = yield* loadConfig(config)
const databaseUrl = resolveDatabaseUrl(loaded.config, Option.getOrUndefined(url))
- const state = await loadPostgresMigrationState(loaded, databaseUrl)
+ const state = yield* loadPostgresMigrationStateEffect(loaded, databaseUrl)
const stepCount = Math.max(1, Option.getOrElse(steps, () => 1))
const applied = [...state.appliedRows].slice(Math.max(0, state.appliedRows.length - stepCount)).reverse()
const fileByName = new Map(state.files.map((file) => [file.name, file]))
@@ -323,23 +314,21 @@ const migrateDown = Command.make(
}
})
if (selected.length === 0) {
- return yield* log("no applied migrations")
+ return yield* Effect.logInfo("no applied migrations")
}
- yield* logLines([
+ yield* outputLines([
`rollback migrations (${selected.length}):`,
...selected.map((file) => ` - ${file.name}`)
])
if (!dryRun) {
- yield* effectFromPromise(() =>
- runPostgresUrl(
- databaseUrl,
- withMigrationLock(
- loaded.config.migrations.table,
- rollbackMigrationFiles(loaded.config.migrations.table, selected)
- )
+ yield* providePostgresUrl(
+ databaseUrl,
+ withMigrationLock(
+ loaded.config.migrations.table,
+ rollbackMigrationFiles(loaded.config.migrations.table, selected)
)
)
- yield* log(`rolled back ${selected.length} migration(s)`)
+ yield* Effect.logInfo(`rolled back ${selected.length} migration(s)`)
}
})
)
@@ -353,10 +342,10 @@ const migrateRepair = Command.make(
},
({ config, url, dryRun }) =>
Effect.gen(function*() {
- const { loaded, databaseUrl, orphanNames } = yield* effectFromPromise(async () => {
- const loaded = await loadPostgresConfig(process.cwd(), Option.getOrUndefined(config))
+ const { loaded, databaseUrl, orphanNames } = yield* Effect.gen(function*() {
+ const loaded = yield* loadConfig(config)
const databaseUrl = resolveDatabaseUrl(loaded.config, Option.getOrUndefined(url))
- const state = await loadPostgresMigrationState(loaded, databaseUrl)
+ const state = yield* loadPostgresMigrationStateEffect(loaded, databaseUrl)
const fileNames = new Set(state.files.map((file) => file.name))
const orphanNames = state.appliedRows
.map((row) => row.name)
@@ -368,23 +357,21 @@ const migrateRepair = Command.make(
}
})
if (orphanNames.length === 0) {
- return yield* log("migration ledger is already aligned")
+ return yield* Effect.logInfo("migration ledger is already aligned")
}
- yield* logLines([
+ yield* outputLines([
`repairing ${orphanNames.length} orphaned migration record(s):`,
...orphanNames.map((name) => ` - ${name}`)
])
if (!dryRun) {
- yield* effectFromPromise(() =>
- runPostgresUrl(
- databaseUrl,
- withMigrationLock(
- loaded.config.migrations.table,
- deleteAppliedMigrationNames(loaded.config.migrations.table, orphanNames)
- )
+ yield* providePostgresUrl(
+ databaseUrl,
+ withMigrationLock(
+ loaded.config.migrations.table,
+ deleteAppliedMigrationNames(loaded.config.migrations.table, orphanNames)
)
)
- yield* log(`repaired ${orphanNames.length} migration record(s)`)
+ yield* Effect.logInfo(`repaired ${orphanNames.length} migration record(s)`)
}
})
)
@@ -402,6 +389,8 @@ const cli = Command.run(root, {
})
cli.pipe(
- Effect.provide(BunServices.layer),
- BunRuntime.runMain
+ Effect.tapCause((cause) => Effect.logError(cause)),
+ Effect.provideService(Logger.LogToStderr, true),
+ Effect.provide(NodeServices.layer),
+ NodeRuntime.runMain({ disableErrorReporting: true })
)
diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts
index 566beed2..758fdf63 100644
--- a/packages/database/src/index.ts
+++ b/packages/database/src/index.ts
@@ -1,6 +1,7 @@
export {
defineConfig,
loadPostgresConfig,
+ loadPostgresConfigEffect,
resolveDatabaseUrl,
type EffectDbConfig,
type FilterConfig,
diff --git a/packages/database/src/internal/node-platform.ts b/packages/database/src/internal/node-platform.ts
new file mode 100644
index 00000000..a9067c82
--- /dev/null
+++ b/packages/database/src/internal/node-platform.ts
@@ -0,0 +1,20 @@
+import { NodePath, NodeServices } from "@effect/platform-node"
+import * as Crypto from "effect/Crypto"
+import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
+
+export type PlatformServices =
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | Path.Path
+
+export const runNodePlatform = (
+ effect: Effect.Effect
+): Promise =>
+ Effect.runPromise(Effect.provide(effect, NodeServices.layer))
+
+export const runNodePath = (
+ effect: Effect.Effect
+): A =>
+ Effect.runSync(Effect.provide(effect, NodePath.layer))
diff --git a/packages/database/src/internal/postgres-config.ts b/packages/database/src/internal/postgres-config.ts
index 4502a747..25c1027a 100644
--- a/packages/database/src/internal/postgres-config.ts
+++ b/packages/database/src/internal/postgres-config.ts
@@ -1,5 +1,8 @@
-import { dirname, resolve } from "node:path"
-import { pathToFileURL } from "node:url"
+import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
+
+import { runNodePlatform } from "./node-platform.js"
export type FilterConfig = {
readonly schemas?: readonly string[]
@@ -244,79 +247,97 @@ const defaultConfig = (): EffectDbConfig => ({
}
})
-const fileExists = async (path: string): Promise =>
- await Bun.file(path).exists()
-
-const loadModuleConfig = async (path: string): Promise => {
- const imported = await import(pathToFileURL(path).href)
- return imported.default ?? imported.config ?? imported
-}
-
-export const loadPostgresConfig = async (
+const loadModuleConfig = (path: string): Effect.Effect =>
+ Effect.gen(function*() {
+ const paths = yield* Path.Path
+ const url = yield* paths.toFileUrl(path)
+ const imported = yield* Effect.tryPromise({
+ try: () => import(url.href),
+ catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
+ })
+ return imported.default ?? imported.config ?? imported
+ })
+
+export const loadPostgresConfigEffect = (
cwd: string,
explicitPath?: string
-): Promise => {
- const configPath = explicitPath === undefined
- ? await (async () => {
- for (const name of DEFAULT_CONFIG_NAMES) {
- const candidate = resolve(cwd, name)
- if (await fileExists(candidate)) {
- return candidate
+): Effect.Effect =>
+ Effect.gen(function*() {
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
+ const configPath = explicitPath === undefined
+ ? yield* Effect.gen(function*() {
+ for (const name of DEFAULT_CONFIG_NAMES) {
+ const candidate = paths.resolve(cwd, name)
+ if (yield* fs.exists(candidate)) {
+ return candidate
+ }
}
- }
- return undefined
- })()
- : resolve(cwd, explicitPath)
+ return undefined
+ })
+ : paths.resolve(cwd, explicitPath)
- if (configPath === undefined) {
- return {
- config: defaultConfig(),
- cwd
+ if (configPath === undefined) {
+ return {
+ config: defaultConfig(),
+ cwd
+ }
}
- }
- const loaded = await loadModuleConfig(configPath)
- if (typeof loaded !== "object" || loaded === null) {
- throw new Error(`Config file '${configPath}' did not export an object`)
- }
- validatePartialPostgresConfig(loaded, "config")
-
- const partial = loaded as Partial
-
- const merged = {
- ...defaultConfig(),
- ...partial,
- db: {
- ...defaultConfig().db,
- ...(partial.db ?? {})
- },
- source: {
- ...defaultConfig().source,
- ...(partial.source ?? {})
- },
- filter: partial.filter === undefined
- ? undefined
- : {
- ...(partial.filter ?? {})
- },
- migrations: {
- ...defaultConfig().migrations,
- ...(partial.migrations ?? {})
- },
- safety: {
- ...defaultConfig().safety,
- ...(partial.safety ?? {})
+ const loaded = yield* loadModuleConfig(configPath)
+ if (typeof loaded !== "object" || loaded === null) {
+ return yield* Effect.fail(new Error(`Config file '${configPath}' did not export an object`))
}
- } satisfies EffectDbConfig
+ yield* Effect.try({
+ try: () => validatePartialPostgresConfig(loaded, "config"),
+ catch: (cause) => cause
+ })
+
+ const partial = loaded as Partial
+
+ const merged = {
+ ...defaultConfig(),
+ ...partial,
+ db: {
+ ...defaultConfig().db,
+ ...(partial.db ?? {})
+ },
+ source: {
+ ...defaultConfig().source,
+ ...(partial.source ?? {})
+ },
+ filter: partial.filter === undefined
+ ? undefined
+ : {
+ ...(partial.filter ?? {})
+ },
+ migrations: {
+ ...defaultConfig().migrations,
+ ...(partial.migrations ?? {})
+ },
+ safety: {
+ ...defaultConfig().safety,
+ ...(partial.safety ?? {})
+ }
+ } satisfies EffectDbConfig
- validateResolvedPostgresConfig(merged)
+ yield* Effect.try({
+ try: () => validateResolvedPostgresConfig(merged),
+ catch: (cause) => cause
+ })
- return {
- config: merged,
- cwd: dirname(configPath),
- path: configPath
- }
-}
+ return {
+ config: merged,
+ cwd: paths.dirname(configPath),
+ path: configPath
+ }
+ })
+
+export const loadPostgresConfig = (
+ cwd: string,
+ explicitPath?: string
+): Promise =>
+ runNodePlatform(loadPostgresConfigEffect(cwd, explicitPath))
export const resolveDatabaseUrl = (
config: EffectDbConfig,
diff --git a/packages/database/src/internal/postgres-runtime.ts b/packages/database/src/internal/postgres-runtime.ts
index f859d002..aeb7dc1a 100644
--- a/packages/database/src/internal/postgres-runtime.ts
+++ b/packages/database/src/internal/postgres-runtime.ts
@@ -3,10 +3,10 @@ import { PgClient } from "@effect/sql-pg"
import * as Effect from "effect/Effect"
import * as Redacted from "effect/Redacted"
-export const providePostgresUrl = (
+export const providePostgresUrl = (
url: string,
- effect: Effect.Effect
-): Effect.Effect =>
+ effect: Effect.Effect
+) =>
Effect.provide(effect, PgClient.layer({
url: Redacted.make(url)
}))
diff --git a/packages/database/src/internal/postgres-source-discovery.ts b/packages/database/src/internal/postgres-source-discovery.ts
index 2dc8e516..183768e5 100644
--- a/packages/database/src/internal/postgres-source-discovery.ts
+++ b/packages/database/src/internal/postgres-source-discovery.ts
@@ -1,8 +1,9 @@
import * as Std from "effect-qb"
-import { randomUUID } from "node:crypto"
-import { rm } from "node:fs/promises"
-import { basename, dirname, extname, join, relative, resolve } from "node:path"
-import { pathToFileURL } from "node:url"
+import * as Crypto from "effect/Crypto"
+import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
+import { glob } from "tinyglobby"
import ts from "typescript"
import {
@@ -13,6 +14,7 @@ import {
tableKey,
type SchemaModel
} from "effect-qb/postgres/metadata"
+import { runNodePlatform } from "./node-platform.js"
type DiscoveryImportInfo = {
readonly postgresModules: Set
@@ -542,139 +544,176 @@ const discoverInFile = (
return declarations
}
-const createTemporaryExportModule = async (
+const createTemporaryExportModuleEffect = (
filePath: string,
names: readonly string[]
-): Promise => {
- const extension = extname(filePath) || ".ts"
- const tempPath = join(dirname(filePath), `.__effect_qb_discovery_${basename(filePath, extension)}_${randomUUID()}${extension}`)
- const contents = await Bun.file(filePath).text()
- await Bun.write(
- tempPath,
- `${contents}\nconst __effect_qb_discovery_exports = { ${names.join(", ")} }\nexport default __effect_qb_discovery_exports\n`
- )
- return tempPath
-}
+): Effect.Effect =>
+ Effect.gen(function*() {
+ const crypto = yield* Crypto.Crypto
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
+ const extension = paths.extname(filePath) || ".ts"
+ const uuid = yield* crypto.randomUUIDv4
+ const tempPath = paths.join(
+ paths.dirname(filePath),
+ `.__effect_qb_discovery_${paths.basename(filePath, extension)}_${uuid}${extension}`
+ )
+ const contents = yield* fs.readFileString(filePath)
+ yield* fs.writeFileString(
+ tempPath,
+ `${contents}\nconst __effect_qb_discovery_exports = { ${names.join(", ")} }\nexport default __effect_qb_discovery_exports\n`
+ )
+ return tempPath
+ })
-const importDiscoveredValues = async (
+const importDiscoveredValuesEffect = (
declarations: readonly SourceDeclaration[]
-): Promise> => {
- const byFile = new Map()
- for (const declaration of declarations) {
- const names = byFile.get(declaration.filePath) ?? []
- names.push(declaration.identifier)
- byFile.set(declaration.filePath, names)
- }
- const values: unknown[] = []
- for (const [filePath, names] of byFile) {
- const tempPath = await createTemporaryExportModule(filePath, [...new Set(names)])
- try {
- const imported = await import(pathToFileURL(tempPath).href)
+): Effect.Effect, unknown, Crypto.Crypto | FileSystem.FileSystem | Path.Path> =>
+ Effect.gen(function*() {
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
+ const byFile = new Map()
+ for (const declaration of declarations) {
+ const names = byFile.get(declaration.filePath) ?? []
+ names.push(declaration.identifier)
+ byFile.set(declaration.filePath, names)
+ }
+ const values: unknown[] = []
+ for (const [filePath, names] of byFile) {
+ const tempPath = yield* createTemporaryExportModuleEffect(filePath, [...new Set(names)])
+ const imported = yield* Effect.flatMap(paths.toFileUrl(tempPath), (url) =>
+ Effect.tryPromise({
+ try: () => import(url.href),
+ catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
+ })).pipe(
+ Effect.ensuring(Effect.ignore(fs.remove(tempPath, { force: true })))
+ )
const exportedValues = imported.default as Record | undefined
for (const name of names) {
values.push(exportedValues?.[name])
}
- } finally {
- await rm(tempPath, { force: true }).catch(() => undefined)
}
- }
- return values
-}
+ return values
+ })
-const scanPattern = async (
+const scanPatternEffect = (
cwd: string,
pattern: string
-): Promise> => {
- const matches: string[] = []
- for await (const match of new Bun.Glob(pattern).scan({ cwd, absolute: true, dot: true, followSymlinks: true })) {
- if (DEFAULT_SOURCE_EXTENSIONS.has(extname(match))) {
- matches.push(resolve(match))
+): Effect.Effect, unknown, Path.Path> =>
+ Effect.gen(function*() {
+ const paths = yield* Path.Path
+ const matches: string[] = []
+ for (const match of yield* Effect.tryPromise({
+ try: () => glob(pattern, {
+ cwd,
+ absolute: true,
+ dot: true,
+ followSymbolicLinks: true
+ }),
+ catch: (cause) => cause instanceof Error ? cause : new Error(String(cause))
+ })) {
+ if (DEFAULT_SOURCE_EXTENSIONS.has(paths.extname(match))) {
+ matches.push(paths.resolve(match))
+ }
}
- }
- return matches
-}
+ return matches
+ })
-export const discoverSourceSchema = async (
+export const discoverSourceSchemaEffect = (
cwd: string,
source: {
readonly include: readonly string[]
readonly exclude?: readonly string[]
}
-): Promise => {
- const included = new Set()
- for (const pattern of source.include) {
- for (const match of await scanPattern(cwd, pattern)) {
- included.add(match)
+): Effect.Effect =>
+ Effect.gen(function*() {
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
+ const included = new Set()
+ for (const pattern of source.include) {
+ for (const match of yield* scanPatternEffect(cwd, pattern)) {
+ included.add(match)
+ }
}
- }
- const excluded = new Set()
- for (const pattern of source.exclude ?? []) {
- for (const match of await scanPattern(cwd, pattern)) {
- excluded.add(match)
+ const excluded = new Set()
+ for (const pattern of source.exclude ?? []) {
+ for (const match of yield* scanPatternEffect(cwd, pattern)) {
+ excluded.add(match)
+ }
}
- }
- const declarations: SourceDeclaration[] = []
- for (const filePath of [...included].filter((file) => !excluded.has(file)).sort()) {
- const contents = await Bun.file(filePath).text()
- declarations.push(...discoverInFile(filePath, contents))
- }
- const duplicateKeys = new Map()
- for (const declaration of declarations) {
- const key = `${declaration.filePath}:${declaration.identifier}`
- if (duplicateKeys.has(key)) {
- throw new Error(`Duplicate discovered declaration '${declaration.identifier}' in '${relative(cwd, declaration.filePath)}'`)
+ const declarations: SourceDeclaration[] = []
+ for (const filePath of [...included].filter((file) => !excluded.has(file)).sort()) {
+ const contents = yield* fs.readFileString(filePath)
+ declarations.push(...discoverInFile(filePath, contents))
}
- duplicateKeys.set(key, key)
- }
- const values = await importDiscoveredValues(declarations)
- const bindings: SourceBinding[] = []
- const seenKeys = new Map()
- for (const [index, value] of values.entries()) {
- const declaration = declarations[index]
- if (declaration === undefined) {
- continue
+ const duplicateKeys = new Map()
+ for (const declaration of declarations) {
+ const key = `${declaration.filePath}:${declaration.identifier}`
+ if (duplicateKeys.has(key)) {
+ return yield* Effect.fail(new Error(
+ `Duplicate discovered declaration '${declaration.identifier}' in '${paths.relative(cwd, declaration.filePath)}'`
+ ))
+ }
+ duplicateKeys.set(key, key)
}
- if (isTableDefinition(value)) {
- const state = (value as any)[Std.Table.TypeId] as {
- readonly schemaName?: string
- readonly baseName: string
+ const values = yield* importDiscoveredValuesEffect(declarations)
+ const bindings: SourceBinding[] = []
+ const seenKeys = new Map()
+ for (const [index, value] of values.entries()) {
+ const declaration = declarations[index]
+ if (declaration === undefined) {
+ continue
}
- const key = sourceIdentityKey(state.schemaName, state.baseName)
- const existing = seenKeys.get(key)
- if (existing) {
- throw new Error(
- `Duplicate discovered table identity '${tableKey(state.schemaName, state.baseName)}' in '${relative(cwd, existing.filePath)}' and '${relative(cwd, declaration.filePath)}'`
- )
+ if (isTableDefinition(value)) {
+ const state = (value as any)[Std.Table.TypeId] as {
+ readonly schemaName?: string
+ readonly baseName: string
+ }
+ const key = sourceIdentityKey(state.schemaName, state.baseName)
+ const existing = seenKeys.get(key)
+ if (existing) {
+ return yield* Effect.fail(new Error(
+ `Duplicate discovered table identity '${tableKey(state.schemaName, state.baseName)}' in '${paths.relative(cwd, existing.filePath)}' and '${paths.relative(cwd, declaration.filePath)}'`
+ ))
+ }
+ seenKeys.set(key, declaration)
+ bindings.push({
+ declaration,
+ value,
+ key,
+ kind: "table"
+ })
+ continue
}
- seenKeys.set(key, declaration)
- bindings.push({
- declaration,
- value,
- key,
- kind: "table"
- })
- continue
- }
- if (isEnumDefinition(value)) {
- const key = sourceIdentityKey(value.schemaName, value.name)
- const existing = seenKeys.get(key)
- if (existing) {
- throw new Error(
- `Duplicate discovered enum identity '${enumKey(value.schemaName, value.name)}' in '${relative(cwd, existing.filePath)}' and '${relative(cwd, declaration.filePath)}'`
- )
+ if (isEnumDefinition(value)) {
+ const key = sourceIdentityKey(value.schemaName, value.name)
+ const existing = seenKeys.get(key)
+ if (existing) {
+ return yield* Effect.fail(new Error(
+ `Duplicate discovered enum identity '${enumKey(value.schemaName, value.name)}' in '${paths.relative(cwd, existing.filePath)}' and '${paths.relative(cwd, declaration.filePath)}'`
+ ))
+ }
+ seenKeys.set(key, declaration)
+ bindings.push({
+ declaration,
+ value,
+ key,
+ kind: "enum"
+ })
}
- seenKeys.set(key, declaration)
- bindings.push({
- declaration,
- value,
- key,
- kind: "enum"
- })
}
+ return {
+ declarations,
+ bindings,
+ model: fromDiscoveredValues(values)
+ }
+ })
+
+export const discoverSourceSchema = (
+ cwd: string,
+ source: {
+ readonly include: readonly string[]
+ readonly exclude?: readonly string[]
}
- return {
- declarations,
- bindings,
- model: fromDiscoveredValues(values)
- }
-}
+): Promise =>
+ runNodePlatform(discoverSourceSchemaEffect(cwd, source))
diff --git a/packages/database/src/postgres/migrate.ts b/packages/database/src/postgres/migrate.ts
index 94581017..51fd3441 100644
--- a/packages/database/src/postgres/migrate.ts
+++ b/packages/database/src/postgres/migrate.ts
@@ -1,15 +1,16 @@
-import { createHash } from "node:crypto"
-import { mkdir } from "node:fs/promises"
-import { join, resolve } from "node:path"
-
+import * as Crypto from "effect/Crypto"
+import * as Encoding from "effect/Encoding"
import * as SqlClient from "effect/unstable/sql/SqlClient"
import * as SqlSchema from "effect/unstable/sql/SqlSchema"
import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
import * as Schema from "effect/Schema"
-import { runPostgresUrl } from "../internal/postgres-runtime.js"
+import { runNodePath, runNodePlatform, type PlatformServices } from "../internal/node-platform.js"
+import type { LoadedPostgresConfig } from "../internal/postgres-config.js"
+import { providePostgresUrl } from "../internal/postgres-runtime.js"
import type { SchemaChange } from "../internal/postgres-schema-diff.js"
-import type { loadPostgresConfig } from "../internal/postgres-config.js"
const MIGRATION_UP_MARKER = "-- effect-db:up"
const MIGRATION_DOWN_MARKER = "-- effect-db:down"
@@ -98,8 +99,14 @@ const migrationTableSql = (tableName: string): string =>
const normalizeMigrationContents = (contents: string): string =>
contents.replaceAll("\r\n", "\n")
-const migrationChecksumOf = (contents: string): string =>
- `${MIGRATION_CHECKSUM_PREFIX}:${createHash("sha256").update(normalizeMigrationContents(contents)).digest("hex")}`
+const migrationChecksumOfEffect = (
+ contents: string
+): Effect.Effect =>
+ Effect.flatMap(Crypto.Crypto, (crypto) =>
+ Effect.map(
+ crypto.digest("SHA-256", new TextEncoder().encode(normalizeMigrationContents(contents))),
+ (digest) => `${MIGRATION_CHECKSUM_PREFIX}:${Encoding.encodeHex(digest)}`
+ ))
export interface MigrationFile {
readonly name: string
@@ -118,13 +125,14 @@ export interface AppliedMigrationRow {
const EmptyRequest = Schema.Struct({})
const AppliedMigrationRowSchema = Schema.Struct({
- id: Schema.Number,
+ id: Schema.Union([
+ Schema.Number.check(Schema.isFinite()),
+ Schema.NumberFromString.check(Schema.isFinite())
+ ]),
name: Schema.String,
checksum: Schema.NullOr(Schema.String)
})
-type LoadedConfig = Awaited>
-
const renderStatements = (statements: readonly string[]): string =>
statements
.map((statement) => statement.endsWith(";") ? statement : `${statement};`)
@@ -195,31 +203,34 @@ export const renderMigrationFile = (
return sections.join("\n")
}
-export const writeMigrationFile = async (
+export const writeMigrationFileEffect = (
migrationsDir: string,
name: string,
changes: readonly SchemaChange[]
-): Promise => {
- const directory = resolve(migrationsDir)
- await mkdir(directory, { recursive: true })
- const files = await Array.fromAsync(new Bun.Glob("*.sql").scan({
- cwd: directory,
- absolute: false
- }))
- const nextNumber = files
- .map((file) => /^(\d+)_/.exec(file)?.[1])
- .filter((value): value is string => value !== undefined)
- .map((value) => Number(value))
- .reduce((max, current) => Math.max(max, current), 0) + 1
- const fileName = `${String(nextNumber).padStart(4, "0")}_${sanitizeName(name)}.sql`
- const filePath = join(directory, fileName)
- await Bun.write(filePath, `${renderMigrationFile(changes)}\n`)
- return filePath
-}
-
-const ensureDirectory = async (dir: string): Promise => {
- await mkdir(resolve(dir), { recursive: true })
-}
+): Effect.Effect =>
+ Effect.gen(function*() {
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
+ const directory = paths.resolve(migrationsDir)
+ yield* fs.makeDirectory(directory, { recursive: true })
+ const files = (yield* fs.readDirectory(directory)).filter((file) => file.endsWith(".sql"))
+ const nextNumber = files
+ .map((file) => /^(\d+)_/.exec(file)?.[1])
+ .filter((value): value is string => value !== undefined)
+ .map((value) => Number(value))
+ .reduce((max, current) => Math.max(max, current), 0) + 1
+ const fileName = `${String(nextNumber).padStart(4, "0")}_${sanitizeName(name)}.sql`
+ const filePath = paths.join(directory, fileName)
+ yield* fs.writeFileString(filePath, `${renderMigrationFile(changes)}\n`)
+ return filePath
+ })
+
+export const writeMigrationFile = (
+ migrationsDir: string,
+ name: string,
+ changes: readonly SchemaChange[]
+): Promise =>
+ runNodePlatform(writeMigrationFileEffect(migrationsDir, name, changes))
export const applyStatements = (
statements: readonly string[]
@@ -233,59 +244,53 @@ export const applyStatements = (
)
))
-export const readPendingMigrationFiles = async (
+export const readPendingMigrationFilesEffect = (
migrationsDir: string,
appliedNames: ReadonlySet
-): Promise> => {
- await ensureDirectory(migrationsDir)
- const directory = resolve(migrationsDir)
- const files = (await Array.fromAsync(new Bun.Glob("*.sql").scan({
- cwd: directory,
- absolute: true
- }))).sort()
- const pending: MigrationFile[] = []
- for (const path of files) {
- const name = path.slice(path.lastIndexOf("/") + 1)
- if (appliedNames.has(name)) {
- continue
+): Effect.Effect, unknown, Crypto.Crypto | FileSystem.FileSystem | Path.Path> =>
+ Effect.gen(function*() {
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
+ const directory = paths.resolve(migrationsDir)
+ yield* fs.makeDirectory(directory, { recursive: true })
+ const files = (yield* fs.readDirectory(directory))
+ .filter((file) => file.endsWith(".sql"))
+ .map((file) => paths.join(directory, file))
+ .sort()
+ const pending: MigrationFile[] = []
+ for (const filePath of files) {
+ const name = paths.basename(filePath)
+ if (appliedNames.has(name)) {
+ continue
+ }
+ const contents = yield* fs.readFileString(filePath)
+ const parsed = parseMigrationSections(contents)
+ pending.push({
+ name,
+ path: filePath,
+ sql: parsed.sql,
+ downSql: parsed.downSql,
+ checksum: yield* migrationChecksumOfEffect(contents)
+ })
}
- const contents = await Bun.file(path).text()
- const parsed = parseMigrationSections(contents)
- pending.push({
- name,
- path,
- sql: parsed.sql,
- downSql: parsed.downSql,
- checksum: migrationChecksumOf(contents)
- })
- }
- return pending
-}
+ return pending
+ })
-export const readMigrationFiles = async (
+export const readPendingMigrationFiles = (
+ migrationsDir: string,
+ appliedNames: ReadonlySet
+): Promise> =>
+ runNodePlatform(readPendingMigrationFilesEffect(migrationsDir, appliedNames))
+
+export const readMigrationFilesEffect = (
migrationsDir: string
-): Promise> => {
- await ensureDirectory(migrationsDir)
- const directory = resolve(migrationsDir)
- const files = (await Array.fromAsync(new Bun.Glob("*.sql").scan({
- cwd: directory,
- absolute: true
- }))).sort()
- const parsed: MigrationFile[] = []
- for (const path of files) {
- const name = path.slice(path.lastIndexOf("/") + 1)
- const contents = await Bun.file(path).text()
- const sections = parseMigrationSections(contents)
- parsed.push({
- name,
- path,
- sql: sections.sql,
- downSql: sections.downSql,
- checksum: migrationChecksumOf(contents)
- })
- }
- return parsed
-}
+): Effect.Effect, unknown, Crypto.Crypto | FileSystem.FileSystem | Path.Path> =>
+ readPendingMigrationFilesEffect(migrationsDir, new Set())
+
+export const readMigrationFiles = (
+ migrationsDir: string
+): Promise> =>
+ runNodePlatform(readMigrationFilesEffect(migrationsDir))
export const ensureMigrationTable = (
tableName: string
@@ -297,10 +302,10 @@ export const ensureMigrationTable = (
yield* Effect.asVoid(sql.unsafe(`alter table ${qualifyIdentifier(tableName)} alter column checksum drop not null`))
}))
-export const withMigrationLock = (
+export const withMigrationLock = (
tableName: string,
- effect: Effect.Effect
-): Effect.Effect =>
+ effect: Effect.Effect
+): Effect.Effect =>
Effect.flatMap(SqlClient.SqlClient, (sql) =>
sql.withTransaction(Effect.gen(function*() {
yield* Effect.asVoid(sql.unsafe(
@@ -471,27 +476,51 @@ export const deleteAppliedMigrationNames = (
export const migrationFileLabel = (path: string): string =>
path.slice(path.lastIndexOf("/") + 1)
+export const migrationDirFromConfigEffect = (
+ cwd: string,
+ dir: string
+): Effect.Effect =>
+ Effect.map(Path.Path, (paths) => paths.resolve(cwd, dir))
+
export const migrationDirFromConfig = (cwd: string, dir: string): string =>
- resolve(cwd, dir)
+ runNodePath(migrationDirFromConfigEffect(cwd, dir))
-export const loadPostgresMigrationState = async (
- loaded: LoadedConfig,
+export const loadPostgresMigrationStateEffect = (
+ loaded: LoadedPostgresConfig,
databaseUrl: string
-) => {
- const files = await readMigrationFiles(migrationDirFromConfig(loaded.cwd, loaded.config.migrations.dir))
- const appliedRows = await runPostgresUrl(
- databaseUrl,
- withMigrationLock(
- loaded.config.migrations.table,
- loadAppliedMigrationRows(loaded.config.migrations.table, files)
+): Effect.Effect<{
+ readonly files: ReadonlyArray
+ readonly appliedRows: ReadonlyArray
+ readonly appliedNames: ReadonlySet
+ readonly pending: ReadonlyArray
+}, unknown, PlatformServices> =>
+ Effect.gen(function*() {
+ const migrationsDir = yield* migrationDirFromConfigEffect(loaded.cwd, loaded.config.migrations.dir)
+ const files = yield* readMigrationFilesEffect(migrationsDir)
+ const appliedRows = yield* providePostgresUrl(
+ databaseUrl,
+ withMigrationLock(
+ loaded.config.migrations.table,
+ loadAppliedMigrationRows(loaded.config.migrations.table, files)
+ )
)
- )
- const appliedNames = new Set(appliedRows.map((row) => row.name))
- const pending = files.filter((file) => !appliedNames.has(file.name))
- return {
- files,
- appliedRows,
- appliedNames,
- pending
- }
-}
+ const appliedNames = new Set(appliedRows.map((row) => row.name))
+ const pending = files.filter((file) => !appliedNames.has(file.name))
+ return {
+ files,
+ appliedRows,
+ appliedNames,
+ pending
+ }
+ })
+
+export const loadPostgresMigrationState = (
+ loaded: LoadedPostgresConfig,
+ databaseUrl: string
+): Promise<{
+ readonly files: ReadonlyArray
+ readonly appliedRows: ReadonlyArray
+ readonly appliedNames: ReadonlySet
+ readonly pending: ReadonlyArray
+}> =>
+ runNodePlatform(loadPostgresMigrationStateEffect(loaded, databaseUrl))
diff --git a/packages/database/src/postgres/pull.ts b/packages/database/src/postgres/pull.ts
index 40879f82..cee41c59 100644
--- a/packages/database/src/postgres/pull.ts
+++ b/packages/database/src/postgres/pull.ts
@@ -1,12 +1,13 @@
-import { mkdir } from "node:fs/promises"
-import { dirname, extname, relative, resolve } from "node:path"
-
+import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
import { Datatypes } from "effect-qb/postgres"
import type { ColumnModel, EnumModel, SchemaModel, TableModel, DdlExpressionLike, IndexKeySpec, TableOptionSpec } from "effect-qb/postgres/metadata"
import { defaultConstraintName } from "../internal/postgres-schema-sql.js"
import { enumKey, tableKey, renderDdlExpressionSql, normalizeDdlExpressionSql, toEnumModel, toTableModel } from "effect-qb/postgres/metadata"
import type { DiscoveredSourceSchema, SourceBinding, SourceDeclaration } from "../internal/postgres-source-discovery.js"
import { canonicalizePostgresTypeName, inferPostgresTypeKind } from "../internal/postgres-type-utils.js"
+import { runNodePath, runNodePlatform } from "../internal/node-platform.js"
import { parse, type Expr as PgSqlExpr } from "pgsql-ast-parser"
const TABLE_ALIAS = "Table"
@@ -471,7 +472,7 @@ const renderQueryColumnReference = (
if (context.tableColumnsAlias !== undefined) {
return renderColumnAccess(context.tableColumnsAlias, name)
}
- return `${PG_ALIAS}.Query.column(${renderStringLiteral(name)}, ${renderQueryTypeExpression(column, context)}${column.nullable ? ", true" : ""})`
+ return `${STD_ROOT_ALIAS}.Query.column(${renderStringLiteral(name)}, ${renderQueryTypeExpression(column, context)}${column.nullable ? ", true" : ""})`
}
type PipeRender = {
@@ -691,7 +692,7 @@ const renderSqlExpressionCode = (
): string => {
const operands = collectBooleanOperands(value, operator.toUpperCase() as "AND" | "OR")
.map((item) => renderSqlExpressionCode(item, context))
- const base = `${PG_ALIAS}.Query.${operator}(${operands.slice(0, 2).join(", ")})`
+ const base = `${STD_ROOT_ALIAS}.Query.${operator}(${operands.slice(0, 2).join(", ")})`
return operands.length > 2
? `${base}.pipe(${operands.slice(2).join(", ")})`
: base
@@ -746,39 +747,39 @@ const renderSqlExpressionCode = (
case "ref":
return renderQueryColumnReference(expression.name, context)
case "string":
- return `${PG_ALIAS}.Query.literal(${renderStringLiteral(expression.value)})`
+ return `${STD_ROOT_ALIAS}.Query.literal(${renderStringLiteral(expression.value)})`
case "integer":
- return `${PG_ALIAS}.Query.literal(${String(expression.value)})`
+ return `${STD_ROOT_ALIAS}.Query.literal(${String(expression.value)})`
case "numeric":
- return `${PG_ALIAS}.Query.literal(${String(expression.value)})`
+ return `${STD_ROOT_ALIAS}.Query.literal(${String(expression.value)})`
case "boolean":
- return `${PG_ALIAS}.Query.literal(${String(expression.value)})`
+ return `${STD_ROOT_ALIAS}.Query.literal(${String(expression.value)})`
case "null":
- return `${PG_ALIAS}.Query.literal(null)`
+ return `${STD_ROOT_ALIAS}.Query.literal(null)`
case "keyword": {
const keyword = (expression.keyword as string).toLowerCase()
switch (keyword) {
case "current_date":
- return `${PG_ALIAS}.Function.currentDate()`
+ return `${STD_ROOT_ALIAS}.Function.currentDate()`
case "current_time":
- return `${PG_ALIAS}.Function.currentTime()`
+ return `${STD_ROOT_ALIAS}.Function.currentTime()`
case "current_timestamp":
- return `${PG_ALIAS}.Function.currentTimestamp()`
+ return `${STD_ROOT_ALIAS}.Function.currentTimestamp()`
case "localtime":
- return `${PG_ALIAS}.Function.localTime()`
+ return `${STD_ROOT_ALIAS}.Function.localTime()`
case "localtimestamp":
- return `${PG_ALIAS}.Function.localTimestamp()`
+ return `${STD_ROOT_ALIAS}.Function.localTimestamp()`
case "current_schema":
case "current_catalog":
case "current_role":
case "current_user":
case "session_user":
case "user":
- return `${PG_ALIAS}.Function.call(${renderStringLiteral(keyword)})`
+ return `${STD_ROOT_ALIAS}.Function.call(${renderStringLiteral(keyword)})`
case "distinct":
throw new Error("Unsupported PostgreSQL keyword in pulled schema: distinct")
}
- return `${PG_ALIAS}.Function.call(${renderStringLiteral(keyword)})`
+ return `${STD_ROOT_ALIAS}.Function.call(${renderStringLiteral(keyword)})`
}
case "cast":
return `${CAST_ALIAS}.to(${renderSqlExpressionCode(expression.operand, context, false)}, ${renderCastTarget(expression.to, context)})`
@@ -799,23 +800,23 @@ const renderSqlExpressionCode = (
const args = Array.isArray(expression.args) ? expression.args : []
switch (name) {
case "lower":
- return `${PG_ALIAS}.Function.lower(${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")})`
+ return `${STD_ROOT_ALIAS}.Function.lower(${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")})`
case "upper":
- return `${PG_ALIAS}.Function.upper(${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")})`
+ return `${STD_ROOT_ALIAS}.Function.upper(${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")})`
case "coalesce":
- return `${PG_ALIAS}.Function.coalesce(${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")})`
+ return `${STD_ROOT_ALIAS}.Function.coalesce(${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")})`
case "now":
- return `${PG_ALIAS}.Function.now()`
+ return `${STD_ROOT_ALIAS}.Function.now()`
case "current_timestamp":
- return `${PG_ALIAS}.Function.currentTimestamp()`
+ return `${STD_ROOT_ALIAS}.Function.currentTimestamp()`
case "current_date":
- return `${PG_ALIAS}.Function.currentDate()`
+ return `${STD_ROOT_ALIAS}.Function.currentDate()`
case "current_time":
- return `${PG_ALIAS}.Function.currentTime()`
+ return `${STD_ROOT_ALIAS}.Function.currentTime()`
case "localtime":
- return `${PG_ALIAS}.Function.localTime()`
+ return `${STD_ROOT_ALIAS}.Function.localTime()`
case "localtimestamp":
- return `${PG_ALIAS}.Function.localTimestamp()`
+ return `${STD_ROOT_ALIAS}.Function.localTimestamp()`
case "uuid_generate_v4":
case "gen_random_uuid":
return `${PG_ALIAS}.Function.uuidGenerateV4()`
@@ -857,7 +858,7 @@ const renderSqlExpressionCode = (
case "jsonb_typeof":
return `${PG_ALIAS}.Jsonb.typeOf(${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")})`
}
- return `${PG_ALIAS}.Function.call(${renderStringLiteral(name)}${args.length === 0 ? "" : `, ${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")}`})`
+ return `${STD_ROOT_ALIAS}.Function.call(${renderStringLiteral(name)}${args.length === 0 ? "" : `, ${args.map((arg) => renderSqlExpressionCode(arg, context)).join(", ")}`})`
}
case "binary": {
const op = expression.op as string
@@ -865,7 +866,7 @@ const renderSqlExpressionCode = (
const anyArgs = Array.isArray(expression.right.args) ? expression.right.args : []
if (anyArgs.length === 1 && anyArgs[0]?.type === "array") {
const arrayValues = anyArgs[0].expressions.map((item: PgSqlExpr) => renderSqlExpressionCode(item, context))
- return `${PG_ALIAS}.Query.in(${renderSqlExpressionCode(expression.left, context)}, ${arrayValues.join(", ")})`
+ return `${STD_ROOT_ALIAS}.Query.in(${renderSqlExpressionCode(expression.left, context)}, ${arrayValues.join(", ")})`
}
}
if (op === "#>" || op === "#>>") {
@@ -884,18 +885,18 @@ const renderSqlExpressionCode = (
const right = renderSqlExpressionCode(expression.right, context)
switch (op) {
case "=":
- return `${PG_ALIAS}.Query.eq(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.eq(${left}, ${right})`
case "!=":
case "<>":
- return `${PG_ALIAS}.Query.neq(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.neq(${left}, ${right})`
case "<":
- return `${PG_ALIAS}.Query.lt(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.lt(${left}, ${right})`
case "<=":
- return `${PG_ALIAS}.Query.lte(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.lte(${left}, ${right})`
case ">":
- return `${PG_ALIAS}.Query.gt(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.gt(${left}, ${right})`
case ">=":
- return `${PG_ALIAS}.Query.gte(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.gte(${left}, ${right})`
case "AND":
case "and":
return renderBooleanChain("and", expression)
@@ -904,18 +905,18 @@ const renderSqlExpressionCode = (
return renderBooleanChain("or", expression)
case "LIKE":
case "like":
- return `${PG_ALIAS}.Query.like(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.like(${left}, ${right})`
case "ILIKE":
case "ilike":
- return `${PG_ALIAS}.Query.ilike(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.ilike(${left}, ${right})`
case "~":
- return `${PG_ALIAS}.Query.regexMatch(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.regexMatch(${left}, ${right})`
case "~*":
- return `${PG_ALIAS}.Query.regexIMatch(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.regexIMatch(${left}, ${right})`
case "!~":
- return `${PG_ALIAS}.Query.regexNotMatch(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.regexNotMatch(${left}, ${right})`
case "!~*":
- return `${PG_ALIAS}.Query.regexNotIMatch(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.regexNotIMatch(${left}, ${right})`
case "?": {
const key = extractStringLiteral(expression.right)
if (key === undefined) {
@@ -942,15 +943,15 @@ const renderSqlExpressionCode = (
case "@@":
return `${PG_ALIAS}.Jsonb.pathMatch(${left}, ${right})`
case "IS DISTINCT FROM":
- return `${PG_ALIAS}.Query.isDistinctFrom(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.isDistinctFrom(${left}, ${right})`
case "IS NOT DISTINCT FROM":
- return `${PG_ALIAS}.Query.isNotDistinctFrom(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.isNotDistinctFrom(${left}, ${right})`
case "@>":
- return `${PG_ALIAS}.Query.contains(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.contains(${left}, ${right})`
case "<@":
- return `${PG_ALIAS}.Query.containedBy(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.containedBy(${left}, ${right})`
case "&&":
- return `${PG_ALIAS}.Query.overlaps(${left}, ${right})`
+ return `${STD_ROOT_ALIAS}.Query.overlaps(${left}, ${right})`
}
throw new Error(`Unsupported PostgreSQL binary operator in pulled schema: ${expression.op}`)
}
@@ -958,23 +959,23 @@ const renderSqlExpressionCode = (
const operand = renderSqlExpressionCode(expression.operand, context)
switch (expression.op.toUpperCase()) {
case "IS NULL":
- return `${PG_ALIAS}.Query.isNull(${operand})`
+ return `${STD_ROOT_ALIAS}.Query.isNull(${operand})`
case "IS NOT NULL":
- return `${PG_ALIAS}.Query.isNotNull(${operand})`
+ return `${STD_ROOT_ALIAS}.Query.isNotNull(${operand})`
case "IS TRUE":
- return `${PG_ALIAS}.Query.and(${PG_ALIAS}.Query.isNotNull(${operand}), ${PG_ALIAS}.Query.eq(${operand}, ${PG_ALIAS}.Query.literal(true)))`
+ return `${STD_ROOT_ALIAS}.Query.and(${STD_ROOT_ALIAS}.Query.isNotNull(${operand}), ${STD_ROOT_ALIAS}.Query.eq(${operand}, ${STD_ROOT_ALIAS}.Query.literal(true)))`
case "IS FALSE":
- return `${PG_ALIAS}.Query.and(${PG_ALIAS}.Query.isNotNull(${operand}), ${PG_ALIAS}.Query.eq(${operand}, ${PG_ALIAS}.Query.literal(false)))`
+ return `${STD_ROOT_ALIAS}.Query.and(${STD_ROOT_ALIAS}.Query.isNotNull(${operand}), ${STD_ROOT_ALIAS}.Query.eq(${operand}, ${STD_ROOT_ALIAS}.Query.literal(false)))`
case "IS NOT TRUE":
- return `${PG_ALIAS}.Query.or(${PG_ALIAS}.Query.isNull(${operand}), ${PG_ALIAS}.Query.eq(${operand}, ${PG_ALIAS}.Query.literal(false)))`
+ return `${STD_ROOT_ALIAS}.Query.or(${STD_ROOT_ALIAS}.Query.isNull(${operand}), ${STD_ROOT_ALIAS}.Query.eq(${operand}, ${STD_ROOT_ALIAS}.Query.literal(false)))`
case "IS NOT FALSE":
- return `${PG_ALIAS}.Query.or(${PG_ALIAS}.Query.isNull(${operand}), ${PG_ALIAS}.Query.eq(${operand}, ${PG_ALIAS}.Query.literal(true)))`
+ return `${STD_ROOT_ALIAS}.Query.or(${STD_ROOT_ALIAS}.Query.isNull(${operand}), ${STD_ROOT_ALIAS}.Query.eq(${operand}, ${STD_ROOT_ALIAS}.Query.literal(true)))`
case "IS UNKNOWN":
- return `${PG_ALIAS}.Query.isNull(${operand})`
+ return `${STD_ROOT_ALIAS}.Query.isNull(${operand})`
case "IS NOT UNKNOWN":
- return `${PG_ALIAS}.Query.isNotNull(${operand})`
+ return `${STD_ROOT_ALIAS}.Query.isNotNull(${operand})`
case "NOT":
- return `${PG_ALIAS}.Query.not(${operand})`
+ return `${STD_ROOT_ALIAS}.Query.not(${operand})`
}
throw new Error(`Unsupported PostgreSQL unary operator in pulled schema: ${expression.op}`)
}
@@ -982,8 +983,8 @@ const renderSqlExpressionCode = (
{
const values = Array.isArray(expression.expressions) ? expression.expressions : []
return values.length === 0
- ? `${PG_ALIAS}.Function.call("array")`
- : `${PG_ALIAS}.Function.call("array", ${values.map((item: PgSqlExpr) => renderSqlExpressionCode(item, context)).join(", ")})`
+ ? `${STD_ROOT_ALIAS}.Function.call("array")`
+ : `${STD_ROOT_ALIAS}.Function.call("array", ${values.map((item: PgSqlExpr) => renderSqlExpressionCode(item, context)).join(", ")})`
}
case "case": {
const whens = Array.isArray(expression.whens) ? expression.whens : []
@@ -991,20 +992,20 @@ const renderSqlExpressionCode = (
throw new Error("Unsupported PostgreSQL case expression in pulled schema")
}
const base = expression.value === null
- ? `${PG_ALIAS}.Query.case()`
+ ? `${STD_ROOT_ALIAS}.Query.case()`
: expression.value === undefined
- ? `${PG_ALIAS}.Query.case()`
- : `${PG_ALIAS}.Query.match(${renderSqlExpressionCode(expression.value, context)})`
+ ? `${STD_ROOT_ALIAS}.Query.case()`
+ : `${STD_ROOT_ALIAS}.Query.match(${renderSqlExpressionCode(expression.value, context)})`
const chained = whens.reduce(
(acc, branch) => `${acc}.when(${renderSqlExpressionCode(branch.when, context)}, ${renderSqlExpressionCode(branch.value, context)})`,
base
)
return expression.else == null
- ? `${chained}.else(${PG_ALIAS}.Query.literal(null))`
+ ? `${chained}.else(${STD_ROOT_ALIAS}.Query.literal(null))`
: `${chained}.else(${renderSqlExpressionCode(expression.else, context)})`
}
case "extract":
- return `${PG_ALIAS}.Function.call("extract", ${renderStringLiteral((expression.field as { readonly name: string }).name)}, ${renderSqlExpressionCode(expression.from, context)})`
+ return `${STD_ROOT_ALIAS}.Function.call("extract", ${renderStringLiteral((expression.field as { readonly name: string }).name)}, ${renderSqlExpressionCode(expression.from, context)})`
default:
throw new Error(`Unsupported PostgreSQL expression in pulled schema: ${expression.type}`)
}
@@ -1202,7 +1203,7 @@ const tryRenderCollateExpressionCode = (
: operator === ">"
? "gt"
: "gte"
- return `${PG_ALIAS}.Query.${method}(${renderDdlExpressionCode(left, context)}, ${renderDdlExpressionCode(right, context)})`
+ return `${STD_ROOT_ALIAS}.Query.${method}(${renderDdlExpressionCode(left, context)}, ${renderDdlExpressionCode(right, context)})`
})
if (binary !== undefined) {
return binary
@@ -1225,7 +1226,7 @@ const tryRenderCollateExpressionCode = (
const renderedCollation = collationParts.length === 1
? renderStringLiteral(collationParts[0]!)
: `[${collationParts.map((part) => renderStringLiteral(part)).join(", ")}]`
- return `${PG_ALIAS}.Query.collate(${renderDdlExpressionCode(expressionSql, context)}, ${renderedCollation})`
+ return `${STD_ROOT_ALIAS}.Query.collate(${renderDdlExpressionCode(expressionSql, context)}, ${renderedCollation})`
})
if (collate !== undefined) {
return collate
@@ -2761,7 +2762,8 @@ const renderCanonicalNewModule = (
const inferSourceRoot = (
cwd: string,
- includes: readonly string[]
+ includes: readonly string[],
+ paths: Path.Path
): string => {
const first = includes[0] ?? "src/**/*.ts"
const wildcard = first.search(/[*?{\[]/)
@@ -2770,12 +2772,12 @@ const inferSourceRoot = (
return cwd
}
if (prefix.endsWith("/")) {
- return resolve(cwd, prefix)
+ return paths.resolve(cwd, prefix)
}
- if (extname(prefix).length > 0) {
- return resolve(cwd, dirname(prefix))
+ if (paths.extname(prefix).length > 0) {
+ return paths.resolve(cwd, paths.dirname(prefix))
}
- return resolve(cwd, prefix)
+ return paths.resolve(cwd, prefix)
}
const renderDeclaredModule = (
@@ -2813,7 +2815,7 @@ const renderDeclaredModule = (
return ensureImports(body)
}
-export const planPostgresPull = async (
+export const planPostgresPullEffect = (
cwd: string,
source: {
readonly include: readonly string[]
@@ -2821,7 +2823,10 @@ export const planPostgresPull = async (
},
discovered: DiscoveredSourceSchema,
database: SchemaModel
-): Promise => {
+): Effect.Effect =>
+ Effect.gen(function*() {
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
const bindingByKey = new Map(discovered.bindings.map((binding) => [binding.key, binding]))
const databaseTablesByKey = new Map(database.tables.map((table) => [schemaObjectKey(table.schemaName, table.name), table]))
const databaseEnumsByKey = new Map(database.enums.map((enumType) => [schemaObjectKey(enumType.schemaName, enumType.name), enumType]))
@@ -2829,7 +2834,7 @@ export const planPostgresPull = async (
bindingByKey,
enumKeys: new Set(databaseEnumsByKey.keys())
}
- const sourceRoot = inferSourceRoot(cwd, source.include)
+ const sourceRoot = inferSourceRoot(cwd, source.include, paths)
const schemaFilePathByName = new Map()
for (const binding of discovered.bindings) {
@@ -2853,21 +2858,24 @@ export const planPostgresPull = async (
}>
}>()
- const ensureFilePlan = async (filePath: string): Promise<{
+ const ensureFilePlan = (filePath: string): Effect.Effect<{
readonly original: string
readonly replacements: SourceBinding[]
readonly additions: Array<{
readonly binding: SourceBinding
readonly model: TableModel | EnumModel
}>
- }> => {
+ }, unknown> => Effect.gen(function*() {
const existing = filePlans.get(filePath)
if (existing !== undefined) {
return existing
}
- const original = await Bun.file(filePath).exists()
- ? await Bun.file(filePath).text()
- : ""
+ const original = yield* fs.readFileString(filePath).pipe(
+ Effect.catchIf(
+ (cause) => cause.reason._tag === "NotFound",
+ () => Effect.succeed("")
+ )
+ )
const created = {
original,
replacements: [] as SourceBinding[],
@@ -2878,24 +2886,24 @@ export const planPostgresPull = async (
}
filePlans.set(filePath, created)
return created
- }
+ })
- const scheduleReplacement = async (
+ const scheduleReplacement = (
binding: SourceBinding,
model: TableModel | EnumModel
- ): Promise => {
- const plan = await ensureFilePlan(binding.declaration.filePath)
+ ): Effect.Effect => Effect.gen(function*() {
+ const plan = yield* ensureFilePlan(binding.declaration.filePath)
plan.replacements.push(binding)
bindingByKey.set(binding.key, binding)
void model
- }
+ })
for (const [key, table] of databaseTablesByKey) {
const binding = bindingByKey.get(key)
if (binding !== undefined && binding.kind === "table") {
matchedSourceBindings.add(binding)
matchedDbTableKeys.add(key)
- await scheduleReplacement(binding, table)
+ yield* scheduleReplacement(binding, table)
}
}
@@ -2904,7 +2912,7 @@ export const planPostgresPull = async (
if (binding !== undefined && binding.kind === "enum") {
matchedSourceBindings.add(binding)
matchedDbEnumKeys.add(key)
- await scheduleReplacement(binding, enumType)
+ yield* scheduleReplacement(binding, enumType)
}
}
@@ -2917,7 +2925,7 @@ export const planPostgresPull = async (
for (const { source: binding, db: table } of renameTablePairs) {
matchedSourceBindings.add(binding)
matchedDbTableKeys.add(schemaObjectKey(table.schemaName, table.name))
- await scheduleReplacement(binding, table)
+ yield* scheduleReplacement(binding, table)
}
const renameEnumPairs = pairUniqueBySignature(
@@ -2929,7 +2937,7 @@ export const planPostgresPull = async (
for (const { source: binding, db: enumType } of renameEnumPairs) {
matchedSourceBindings.add(binding)
matchedDbEnumKeys.add(schemaObjectKey(enumType.schemaName, enumType.name))
- await scheduleReplacement(binding, enumType)
+ yield* scheduleReplacement(binding, enumType)
}
const newBindingsByFile = new Map => {
- for (const update of plan.updates) {
- await mkdir(dirname(update.filePath), { recursive: true })
- await Bun.write(update.filePath, update.after)
- }
-}
+export const planPostgresPull = (
+ cwd: string,
+ source: {
+ readonly include: readonly string[]
+ readonly exclude?: readonly string[]
+ },
+ discovered: DiscoveredSourceSchema,
+ database: SchemaModel
+): Promise =>
+ runNodePlatform(planPostgresPullEffect(cwd, source, discovered, database))
+
+export const applyPullPlanEffect = (
+ plan: PullPlan
+): Effect.Effect =>
+ Effect.gen(function*() {
+ const fs = yield* FileSystem.FileSystem
+ const paths = yield* Path.Path
+ for (const update of plan.updates) {
+ yield* fs.makeDirectory(paths.dirname(update.filePath), { recursive: true })
+ yield* fs.writeFileString(update.filePath, update.after)
+ }
+ })
+
+export const applyPullPlan = (plan: PullPlan): Promise =>
+ runNodePlatform(applyPullPlanEffect(plan))
+
+export const summarizePullPlanEffect = (
+ cwd: string,
+ plan: PullPlan
+): Effect.Effect =>
+ Effect.map(Path.Path, (paths) =>
+ plan.updates.map((update) =>
+ `${update.before.length === 0 ? "create" : "update"} ${paths.relative(cwd, update.filePath)}`
+ ))
export const summarizePullPlan = (cwd: string, plan: PullPlan): readonly string[] =>
- plan.updates.map((update) => `${update.before.length === 0 ? "create" : "update"} ${relative(cwd, update.filePath)}`)
+ runNodePath(summarizePullPlanEffect(cwd, plan))
diff --git a/packages/database/src/postgres/push.ts b/packages/database/src/postgres/push.ts
index 7d3146b8..93a82deb 100644
--- a/packages/database/src/postgres/push.ts
+++ b/packages/database/src/postgres/push.ts
@@ -1,14 +1,13 @@
import { tableKey, type SchemaModel } from "effect-qb/postgres/metadata"
+import * as Effect from "effect/Effect"
import { introspectPostgresSchema } from "../internal/postgres-introspector.js"
-import { runPostgresUrl } from "../internal/postgres-runtime.js"
+import { runNodePlatform, type PlatformServices } from "../internal/node-platform.js"
+import type { EffectDbConfig } from "../internal/postgres-config.js"
+import { providePostgresUrl } from "../internal/postgres-runtime.js"
import { planPostgresSchemaDiff, type SchemaChange, type SchemaPlan } from "../internal/postgres-schema-diff.js"
-import { discoverSourceSchema } from "../internal/postgres-source-discovery.js"
+import { discoverSourceSchemaEffect, type DiscoveredSourceSchema } from "../internal/postgres-source-discovery.js"
import { filterDiscoveredSourceSchema } from "../internal/postgres-source-filter.js"
-import type { loadPostgresConfig } from "../internal/postgres-config.js"
-
-type LoadedConfig = Awaited>
-type EffectDbConfig = LoadedConfig["config"]
export type { SchemaChange, SchemaPlan }
@@ -114,24 +113,35 @@ export const withoutManagedMigrationTable = (
}
}
-export const loadPostgresSchemaPlan = async (
+export const loadPostgresSchemaPlanEffect = (
+ cwd: string,
+ config: EffectDbConfig,
+ databaseUrl: string
+): Effect.Effect<{
+ readonly plan: SchemaPlan
+ readonly discovered: DiscoveredSourceSchema
+}, unknown, PlatformServices> =>
+ Effect.gen(function*() {
+ const discovered = filterDiscoveredSourceSchema(
+ yield* discoverSourceSchemaEffect(cwd, config.source),
+ config.filter
+ )
+ const database = withoutManagedMigrationTable(
+ yield* providePostgresUrl(databaseUrl, introspectPostgresSchema(config.filter)),
+ config.migrations.table
+ )
+ return {
+ plan: planPostgresSchemaDiff(discovered.model, database),
+ discovered
+ }
+ })
+
+export const loadPostgresSchemaPlan = (
cwd: string,
config: EffectDbConfig,
databaseUrl: string
): Promise<{
readonly plan: SchemaPlan
- readonly discovered: Awaited>
-}> => {
- const discovered = filterDiscoveredSourceSchema(
- await discoverSourceSchema(cwd, config.source),
- config.filter
- )
- const database = withoutManagedMigrationTable(
- await runPostgresUrl(databaseUrl, introspectPostgresSchema(config.filter)),
- config.migrations.table
- )
- return {
- plan: planPostgresSchemaDiff(discovered.model, database),
- discovered
- }
-}
+ readonly discovered: DiscoveredSourceSchema
+}> =>
+ runNodePlatform(loadPostgresSchemaPlanEffect(cwd, config, databaseUrl))
diff --git a/packages/querybuilder/package.json b/packages/querybuilder/package.json
index ab4b28e0..3696e422 100644
--- a/packages/querybuilder/package.json
+++ b/packages/querybuilder/package.json
@@ -12,8 +12,7 @@
},
"homepage": "https://github.com/relsunkaev/effect-qb#readme",
"engines": {
- "node": ">=22",
- "bun": ">=1.3.5"
+ "node": ">=22"
},
"files": [
"dist",
diff --git a/scripts/check-packed-querybuilder.ts b/scripts/check-packed-querybuilder.ts
index 68299b32..24ae49cb 100644
--- a/scripts/check-packed-querybuilder.ts
+++ b/scripts/check-packed-querybuilder.ts
@@ -1,10 +1,11 @@
-import { mkdtemp, rm } from "node:fs/promises"
+import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises"
import { tmpdir } from "node:os"
import { dirname, join, resolve } from "node:path"
const cwd = process.cwd()
const querybuilderPackageDir = join(cwd, "packages", "querybuilder")
const databasePackageDir = join(cwd, "packages", "database")
+const postgresUrl = process.env.EFFECT_DB_SMOKE_POSTGRES_URL
const querybuilderTarballPath = async () => {
const proc = Bun.spawn([
@@ -73,6 +74,7 @@ const main = async () => {
const packedTarball = await querybuilderTarballPath()
const packedDatabaseTarball = await databaseTarballPath()
const consumerDir = await mkdtemp(join(tmpdir(), "effect-qb-pack-smoke-"))
+ const nodeOnlyBinDir = await mkdtemp(join(tmpdir(), "effect-db-node-bin-"))
try {
await Bun.write(join(consumerDir, "package.json"), `${JSON.stringify({
@@ -241,8 +243,115 @@ const main = async () => {
await run(["bun", "install", "--no-save"], consumerDir)
await run([join(cwd, "node_modules", ".bin", "tsgo"), "-p", "tsconfig.json"], consumerDir)
+
+ const nodePath = Bun.which("node")
+ if (nodePath === null) {
+ throw new Error("Node.js is required for the packed effect-db smoke test")
+ }
+ await symlink(nodePath, join(nodeOnlyBinDir, "node"))
+ await Bun.write(join(consumerDir, "node-smoke.mjs"), `
+import { mkdir, readFile, writeFile } from "node:fs/promises"
+import { join } from "node:path"
+import { loadPostgresConfig } from "effect-db"
+import { readMigrationFiles, writeMigrationFile } from "effect-db/postgres/migrate"
+import { applyPullPlan } from "effect-db/postgres/pull"
+
+const workspace = join(process.cwd(), "node-runtime-workspace")
+await mkdir(workspace)
+await writeFile(join(workspace, "effectdb.config.ts"), \`import { defineConfig } from "effect-db"
+
+const config = {
+ dialect: "postgres",
+ db: { url: "postgres://localhost/effect_db" },
+ source: { include: ["src/**/*.ts"] },
+ migrations: { dir: "migrations", table: "effect_qb_migrations" },
+ safety: { nonDestructiveDefault: true }
+} satisfies Parameters[0]
+
+export default defineConfig(config)\n\`)
+const loaded = await loadPostgresConfig(workspace)
+if (loaded.config.dialect !== "postgres") throw new Error("failed to load config under Node.js")
+
+const migrationsDir = join(workspace, "migrations")
+await writeMigrationFile(migrationsDir, "node runtime", [])
+const migrations = await readMigrationFiles(migrationsDir)
+if (migrations.length !== 1) throw new Error("failed to read migrations under Node.js")
+
+const pulledPath = join(workspace, "src", "schema.ts")
+await applyPullPlan({ updates: [{ filePath: pulledPath, before: "", after: "export {}\\n" }] })
+if (await readFile(pulledPath, "utf8") !== "export {}\\n") throw new Error("failed to apply pull plan under Node.js")
+`)
+ await run([nodePath, "node-smoke.mjs"], consumerDir)
+ const runCli = async (args: readonly string[]) => {
+ const cli = Bun.spawn([
+ join(consumerDir, "node_modules", ".bin", "effectdb"),
+ ...args
+ ], {
+ cwd: consumerDir,
+ env: {
+ ...process.env,
+ PATH: nodeOnlyBinDir
+ },
+ stdout: "pipe",
+ stderr: "pipe"
+ })
+ const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(cli.stdout).text(),
+ new Response(cli.stderr).text(),
+ cli.exited
+ ])
+ return { stdout, stderr, exitCode }
+ }
+
+ const help = await runCli(["--help"])
+ if (help.exitCode !== 0 || !help.stdout.includes("effectdb")) {
+ throw new Error(`Packed effect-db CLI failed under Node.js:\n${help.stdout}${help.stderr}`)
+ }
+
+ if (postgresUrl !== undefined) {
+ const liveWorkspace = join(consumerDir, "packed-cli-live")
+ const schemaName = `pack_smoke_${crypto.randomUUID().replaceAll("-", "")}`
+ await mkdir(liveWorkspace)
+ await Bun.write(join(liveWorkspace, "effectdb.config.mjs"), `
+import { defineConfig } from "effect-db"
+
+export default defineConfig({
+ dialect: "postgres",
+ db: { url: ${JSON.stringify(postgresUrl)} },
+ source: { include: ["schema.mjs"] },
+ filter: { schemas: [${JSON.stringify(schemaName)}] },
+ migrations: { dir: "migrations", table: ${JSON.stringify(`${schemaName}.effect_qb_migrations`)} },
+ safety: { nonDestructiveDefault: true }
+})
+`)
+ await Bun.write(join(liveWorkspace, "schema.mjs"), `
+import { Column, Table } from "effect-qb"
+import * as Pg from "effect-qb/postgres"
+
+const db = Pg.Schema.make(${JSON.stringify(schemaName)})
+const users = db.table("users", {
+ id: Column.uuid()
+}).pipe(Table.primaryKey((table) => table.id))
+
+export { users }
+`)
+ const push = await runCli([
+ "push",
+ "--config",
+ join(liveWorkspace, "effectdb.config.mjs"),
+ "--dry-run"
+ ])
+ if (
+ push.exitCode !== 0 ||
+ !push.stdout.includes(`create schema ${schemaName}`) ||
+ !push.stdout.includes(`create table ${schemaName}.users`)
+ ) {
+ throw new Error(`Packed effect-db CLI live smoke failed under Node.js:\n${push.stdout}${push.stderr}`)
+ }
+ }
} finally {
await rm(consumerDir, { recursive: true, force: true })
+ await rm(nodeOnlyBinDir, { recursive: true, force: true })
await rm(packedTarball, { force: true })
await rm(packedDatabaseTarball, { force: true })
await rm(dirname(packedDatabaseTarball), { recursive: true, force: true })
diff --git a/scripts/test-integration.ts b/scripts/test-integration.ts
index 3f921bcc..a5e0c773 100644
--- a/scripts/test-integration.ts
+++ b/scripts/test-integration.ts
@@ -2,11 +2,28 @@ import { $ } from "bun"
const composeFile = "docker-compose.integration.yml"
const bunfig = "./bunfig.integration.toml"
+const postgresUrl = "postgres://effect_qb:effect_qb@127.0.0.1:55432/effect_qb_test"
+
+const runPackedSmoke = async () => {
+ const proc = Bun.spawn(["bun", "run", "test:pack"], {
+ env: {
+ ...process.env,
+ EFFECT_DB_SMOKE_POSTGRES_URL: postgresUrl
+ },
+ stdout: "inherit",
+ stderr: "inherit"
+ })
+ const exitCode = await proc.exited
+ if (exitCode !== 0) {
+ throw new Error(`Packed package smoke failed with exit code ${exitCode}`)
+ }
+}
const main = async () => {
try {
await $`docker compose -f ${composeFile} up -d --wait`
- await $`bun --config ${bunfig} test --timeout 60000 ./test/public/integration/*.integration.ts`
+ await runPackedSmoke()
+ await $`bun --config=${bunfig} test --timeout 60000 ./test/public/integration/*.integration.ts`
} finally {
try {
await $`docker compose -f ${composeFile} down -v`
diff --git a/test/internal/behavior/postgres-config.behavior.test.ts b/test/internal/behavior/postgres-config.behavior.test.ts
index b3f3c2a0..72e26996 100644
--- a/test/internal/behavior/postgres-config.behavior.test.ts
+++ b/test/internal/behavior/postgres-config.behavior.test.ts
@@ -2,12 +2,40 @@ import { mkdtemp, rm } from "node:fs/promises"
import { join } from "node:path"
import { describe, expect, test } from "bun:test"
+import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
import { loadPostgresConfig, resolveDatabaseUrl } from "effect-db"
+import { loadPostgresConfigEffect } from "../../../packages/database/src/internal/postgres-config.js"
const repoRoot = process.cwd()
describe("postgres config", () => {
+ test("loads defaults through provided platform services", async () => {
+ const checkedPaths: string[] = []
+ const loaded = await Effect.runPromise(
+ loadPostgresConfigEffect("/workspace").pipe(
+ Effect.provide(FileSystem.layerNoop({
+ exists: (path) => {
+ checkedPaths.push(path)
+ return Effect.succeed(false)
+ }
+ })),
+ Effect.provide(Path.layer)
+ )
+ )
+
+ expect(loaded.cwd).toBe("/workspace")
+ expect(loaded.path).toBeUndefined()
+ expect(checkedPaths).toEqual([
+ "/workspace/effectdb.config.ts",
+ "/workspace/effectdb.config.mts",
+ "/workspace/effectdb.config.js",
+ "/workspace/effectdb.config.mjs"
+ ])
+ })
+
test("rejects invalid dialects", async () => {
const tempDir = await mkdtemp(join(repoRoot, "test/.tmp-postgres-config-"))
try {
diff --git a/test/internal/behavior/postgres-migrate.behavior.test.ts b/test/internal/behavior/postgres-migrate.behavior.test.ts
index 10854b73..77dcc246 100644
--- a/test/internal/behavior/postgres-migrate.behavior.test.ts
+++ b/test/internal/behavior/postgres-migrate.behavior.test.ts
@@ -3,15 +3,70 @@ import { join } from "node:path"
import * as SqlClient from "effect/unstable/sql/SqlClient"
import { describe, expect, test } from "bun:test"
+import * as Crypto from "effect/Crypto"
import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
-import { readMigrationFiles, ensureMigrationTable, readAppliedMigrationRows } from "../../../packages/database/src/postgres/migrate.js"
+import { readMigrationFiles, readMigrationFilesEffect, writeMigrationFileEffect, ensureMigrationTable, readAppliedMigrationRows } from "../../../packages/database/src/postgres/migrate.js"
import { withoutManagedMigrationTable } from "../../../packages/database/src/postgres/push.js"
import type { SchemaModel } from "effect-qb/postgres/metadata"
const repoRoot = process.cwd()
describe("postgres migrations", () => {
+ test("reads migrations through provided platform services", async () => {
+ const files = await Effect.runPromise(
+ readMigrationFilesEffect("/migrations").pipe(
+ Effect.provide(FileSystem.layerNoop({
+ makeDirectory: () => Effect.void,
+ readDirectory: () => Effect.succeed(["0001_init.sql"]),
+ readFileString: () => Effect.succeed("create table users (id integer);\n")
+ })),
+ Effect.provide(Path.layer),
+ Effect.provideService(Crypto.Crypto, Crypto.make({
+ randomBytes: (size) => new Uint8Array(size),
+ digest: () => Effect.succeed(Uint8Array.from([1, 2, 255]))
+ }))
+ )
+ )
+
+ expect(files).toEqual([{
+ name: "0001_init.sql",
+ path: "/migrations/0001_init.sql",
+ sql: "create table users (id integer);",
+ checksum: "sha256:0102ff"
+ }])
+ })
+
+ test("writes migrations through provided platform services", async () => {
+ const directories: string[] = []
+ const writes: Array<{ readonly path: string; readonly contents: string }> = []
+ const filePath = await Effect.runPromise(
+ writeMigrationFileEffect("/migrations", "add users", []).pipe(
+ Effect.provide(FileSystem.layerNoop({
+ makeDirectory: (path) => {
+ directories.push(path)
+ return Effect.void
+ },
+ readDirectory: () => Effect.succeed(["0002_previous.sql", "README.md"]),
+ writeFileString: (path, contents) => {
+ writes.push({ path, contents })
+ return Effect.void
+ }
+ })),
+ Effect.provide(Path.layer)
+ )
+ )
+
+ expect(filePath).toBe("/migrations/0003_add_users.sql")
+ expect(directories).toEqual(["/migrations"])
+ expect(writes).toEqual([{
+ path: "/migrations/0003_add_users.sql",
+ contents: "-- effect-db:up\n\n"
+ }])
+ })
+
test("parses up and down sections and normalizes checksum line endings", async () => {
const tempDir = await mkdtemp(join(repoRoot, "test/.tmp-postgres-migrate-"))
try {
@@ -101,7 +156,7 @@ describe("postgres migrations", () => {
if (statement.includes("select id, name, checksum")) {
return Effect.succeed([
{
- id: "1",
+ id: "not-a-number",
name: "0001_init.sql",
checksum: null
}
diff --git a/test/internal/behavior/postgres-schema-management.behavior.test.ts b/test/internal/behavior/postgres-schema-management.behavior.test.ts
index 37b71cbc..04cb2f84 100644
--- a/test/internal/behavior/postgres-schema-management.behavior.test.ts
+++ b/test/internal/behavior/postgres-schema-management.behavior.test.ts
@@ -3,6 +3,9 @@ import { join, relative } from "node:path"
// @ts-nocheck
import { describe, expect, test } from "bun:test"
+import * as Effect from "effect/Effect"
+import * as FileSystem from "effect/FileSystem"
+import * as Path from "effect/Path"
import * as Schema from "effect/Schema"
import * as Pg from "#postgres"
@@ -16,12 +19,44 @@ import {
discoverSourceSchema,
type DiscoveredSourceSchema
} from "../../../packages/database/src/internal/postgres-source-discovery.js"
-import { planPostgresPull } from "../../../packages/database/src/postgres/pull.js"
+import { applyPullPlanEffect, planPostgresPull } from "../../../packages/database/src/postgres/pull.js"
import * as StdRoot from "#standard"
const repoRoot = process.cwd()
describe("postgres schema management", () => {
+ test("applies pull plans through provided platform services", async () => {
+ const directories: string[] = []
+ const writes: Array<{ readonly path: string; readonly contents: string }> = []
+ await Effect.runPromise(
+ applyPullPlanEffect({
+ updates: [{
+ filePath: "/workspace/src/schema.ts",
+ before: "",
+ after: "export {}\n"
+ }]
+ }).pipe(
+ Effect.provide(FileSystem.layerNoop({
+ makeDirectory: (path) => {
+ directories.push(path)
+ return Effect.void
+ },
+ writeFileString: (path, contents) => {
+ writes.push({ path, contents })
+ return Effect.void
+ }
+ })),
+ Effect.provide(Path.layer)
+ )
+ )
+
+ expect(directories).toEqual(["/workspace/src"])
+ expect(writes).toEqual([{
+ path: "/workspace/src/schema.ts",
+ contents: "export {}\n"
+ }])
+ })
+
test("source table models use casing metadata as physical identifiers", () => {
const organizations = StdRoot.Table.make("OrganizationAccounts", {
id: StdRoot.Column.uuid().pipe(StdRoot.Column.primaryKey),
diff --git a/test/public/integration/postgres-cli.integration.ts b/test/public/integration/postgres-cli.integration.ts
index 8c6ab9d2..367336ce 100644
--- a/test/public/integration/postgres-cli.integration.ts
+++ b/test/public/integration/postgres-cli.integration.ts
@@ -6,7 +6,11 @@ import { dirname, join } from "node:path"
import { execPostgres, withPostgresLock } from "./helpers.ts"
const repoRoot = process.cwd()
-const cliEntry = join(repoRoot, "packages", "database", "src", "cli.ts")
+const cliEntry = join(repoRoot, "packages", "database", "dist", "cli.js")
+const nodePath = Bun.which("node")
+if (nodePath === null) {
+ throw new Error("Node.js is required for effect-db CLI integration tests")
+}
const postgresUrl = "postgres://effect_qb:effect_qb@127.0.0.1:55432/effect_qb_test"
const randomId = () => Math.random().toString(36).slice(2, 10)
@@ -59,7 +63,7 @@ const renderTableSource = (
) => `
import * as Pg from "effect-qb/postgres"
import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make(${JSON.stringify(schemaName)})
@@ -145,7 +149,7 @@ const runCli = async (...args: readonly string[]): Promise<{
}> => {
return withPostgresLock(async () => {
const proc = Bun.spawn([
- process.execPath,
+ nodePath,
cliEntry,
...args
], {
@@ -173,7 +177,7 @@ const runCliUnlocked = async (...args: readonly string[]): Promise<{
readonly stderr: string
}> => {
const proc = Bun.spawn([
- process.execPath,
+ nodePath,
cliEntry,
...args
], {
@@ -237,17 +241,27 @@ const listConstraints = (schemaName: string, tableName: string) =>
const assertIdempotentPullPush = async (config: string) => {
const secondPullDryRun = await runCli("pull", "--config", config, "--dry-run")
expect(secondPullDryRun.exitCode).toBe(0)
- expect(secondPullDryRun.stdout).toContain("schema definitions are already up to date")
+ expect(secondPullDryRun.stderr).toContain("schema definitions are already up to date")
const secondPull = await runCli("pull", "--config", config)
expect(secondPull.exitCode).toBe(0)
- expect(secondPull.stdout).toContain("schema definitions are already up to date")
+ expect(secondPull.stderr).toContain("schema definitions are already up to date")
const pushDryRun = await runCli("push", "--config", config, "--dry-run")
expect(pushDryRun.exitCode).toBe(0)
expect(pushDryRun.stdout).toContain("planned changes: none")
}
+test("postgres cli reports fatal failures on stderr", async () => {
+ const missingConfig = join(repoRoot, "test", `missing-effectdb-${randomId()}.config.mjs`)
+ const result = await runCliUnlocked("pull", "--config", missingConfig, "--dry-run")
+
+ expect(result.exitCode).toBe(1)
+ expect(result.stdout).toBe("")
+ expect(result.stderr).toContain("ERROR")
+ expect(result.stderr).toContain("missing-effectdb-")
+})
+
test("postgres cli supports push pull and migrations against a live database", async () => {
const { workspace, schemaName } = await makeWorkspace()
try {
@@ -262,7 +276,7 @@ test("postgres cli supports push pull and migrations against a live database", a
const push = await runCli("push", "--config", config)
expect(push.exitCode).toBe(0)
- expect(push.stdout).toContain("applied 2 statement(s)")
+ expect(push.stderr).toContain("applied 2 statement(s)")
const createdTables = await execPostgres(
`select tablename from pg_tables where schemaname = $1 order by tablename`,
@@ -281,16 +295,23 @@ test("postgres cli supports push pull and migrations against a live database", a
const pull = await runCli("pull", "--config", config)
expect(pull.exitCode).toBe(0)
- expect(pull.stdout).toContain("updated 1 file(s)")
+ expect(pull.stderr).toContain("updated 1 file(s)")
const pulledSchema = await readSchema(workspace)
expect(pulledSchema).toContain(`name: Column.text().pipe(Column.nullable)`)
expect(pulledSchema).toContain(`users_email_idx`)
expect(pulledSchema).toContain(`export { users }`)
- const secondPullDryRun = await runCli("pull", "--config", config, "--dry-run")
+ const secondPullDryRun = await runCli("--log-level", "debug", "pull", "--config", config, "--dry-run")
expect(secondPullDryRun.exitCode).toBe(0)
- expect(secondPullDryRun.stdout).toContain("schema definitions are already up to date")
+ expect(secondPullDryRun.stderr).toContain("schema definitions are already up to date")
+ expect(secondPullDryRun.stderr).toContain("loaded database config")
+
+ const quietPullDryRun = await runCli("--log-level", "none", "pull", "--config", config, "--dry-run")
+ expect(quietPullDryRun.exitCode).toBe(0)
+ expect(`${quietPullDryRun.stdout}\n${quietPullDryRun.stderr}`).not.toContain(
+ "schema definitions are already up to date"
+ )
await writeFile(
schemaFile(workspace),
@@ -302,18 +323,18 @@ test("postgres cli supports push pull and migrations against a live database", a
const migrateGenerate = await runCli("migrate", "generate", "--config", config, "--name", "add_nickname")
expect(migrateGenerate.exitCode).toBe(0)
- expect(migrateGenerate.stdout).toContain("wrote 0001_add_nickname.sql")
+ expect(migrateGenerate.stderr).toContain("wrote 0001_add_nickname.sql")
const migrationSql = await readFile(join(workspace, "migrations", "0001_add_nickname.sql"), "utf8")
expect(migrationSql).toContain(`alter table "${schemaName}"."users" add column "nickname" text;`)
const migrateUp = await runCli("migrate", "up", "--config", config)
expect(migrateUp.exitCode).toBe(0)
- expect(migrateUp.stdout).toContain("applied 1 migration(s)")
+ expect(migrateUp.stderr).toContain("applied 1 migration(s)")
const secondMigrateUp = await runCli("migrate", "up", "--config", config)
expect(secondMigrateUp.exitCode).toBe(0)
- expect(secondMigrateUp.stdout).toContain("no pending migrations")
+ expect(secondMigrateUp.stderr).toContain("no pending migrations")
const userColumns = await listColumns(schemaName, "users")
expect(userColumns).toEqual([
@@ -334,7 +355,7 @@ test("postgres cli supports push pull and migrations against a live database", a
const noOpGenerate = await runCli("migrate", "generate", "--config", config)
expect(noOpGenerate.exitCode).toBe(0)
- expect(noOpGenerate.stdout).toContain("no executable migration changes selected")
+ expect(noOpGenerate.stderr).toContain("no executable migration changes selected")
} finally {
await dropSchema(schemaName).catch(() => undefined)
await rm(workspace, { recursive: true, force: true })
@@ -359,8 +380,8 @@ test("postgres cli blocks destructive push changes unless explicitly allowed", a
const safePush = await runCli("push", "--config", config)
expect(safePush.exitCode).toBe(0)
expect(safePush.stdout).toContain(`drop column ${schemaName}.users.email`)
- expect(safePush.stdout).toContain("no executable statements selected")
- expect(safePush.stdout).toContain("skipped changes:")
+ expect(safePush.stderr).toContain("no executable statements selected")
+ expect(safePush.stderr).toContain("skipped changes:")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -369,7 +390,7 @@ test("postgres cli blocks destructive push changes unless explicitly allowed", a
const destructivePush = await runCli("push", "--config", config, "--allow-destructive")
expect(destructivePush.exitCode).toBe(0)
- expect(destructivePush.stdout).toContain("applied 1 statement(s)")
+ expect(destructivePush.stderr).toContain("applied 1 statement(s)")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" }
@@ -384,12 +405,12 @@ test("postgres cli safe mode applies additive changes and skips destructive drif
const { workspace, schemaName } = await makeSourceWorkspace(`
import * as Pg from "effect-qb/postgres"
import { Cast, Check, Function as F, Index, PrimaryKey, Query as Q, Table, Unique } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
export const users = db.table("users", {
- id: C.int().pipe(C.identityByDefault),
+ id: C.int().pipe(Pg.Column.identityByDefault),
email: C.text(),
nickname: C.text().pipe(C.nullable),
displayName: C.text().pipe(C.default(Cast.to(Q.literal("guest"), Q.type.text()))),
@@ -412,13 +433,13 @@ export const users = db.table("users", {
await writeFile(schemaFile(workspace), `
import * as Pg from "effect-qb/postgres"
import { Cast, Function as F, PrimaryKey, Query as Q, Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make(${JSON.stringify(schemaName)})
export const users = db.table("users", {
- id: C.int().pipe(C.identityByDefault),
- email: C.text().pipe(C.ddlType("character varying(255)")),
+ id: C.int().pipe(Pg.Column.identityByDefault),
+ email: C.text().pipe(Pg.Column.ddlType("character varying(255)")),
nickname: C.text(),
displayName: C.text().pipe(C.default(Cast.to(Q.literal("member"), Q.type.text()))),
emailLower: C.text().pipe(C.generated(F.upper(Q.column("email", Q.type.text())))),
@@ -431,7 +452,7 @@ export const users = db.table("users", {
const safePush = await runCli("push", "--config", config)
expect(safePush.exitCode).toBe(0)
expect(safePush.stdout).toContain(`add column ${schemaName}.users.notes`)
- expect(safePush.stdout).toContain("applied 1 statement(s)")
+ expect(safePush.stderr).toContain("applied 1 statement(s)")
expect(safePush.stdout).toContain(`drop constraint ${schemaName}.users.users_email_check`)
expect(safePush.stdout).toContain(`drop constraint ${schemaName}.users.users_email_key`)
expect(safePush.stdout).toContain(`drop index ${schemaName}.users.users_email_idx`)
@@ -439,7 +460,7 @@ export const users = db.table("users", {
expect(safePush.stdout).toContain(`replace column ${schemaName}.users.email (drop)`)
expect(safePush.stdout).toContain(`replace column ${schemaName}.users.emailLower (drop)`)
expect(safePush.stdout).toContain(`replace column ${schemaName}.users.nickname (drop)`)
- expect(safePush.stdout).toContain("skipped changes:")
+ expect(safePush.stderr).toContain("skipped changes:")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -471,8 +492,8 @@ export const users = db.table("users", {
const secondSafePush = await runCli("push", "--config", config)
expect(secondSafePush.exitCode).toBe(0)
- expect(secondSafePush.stdout).toContain("no executable statements selected")
- expect(secondSafePush.stdout).toContain("skipped changes:")
+ expect(secondSafePush.stderr).toContain("no executable statements selected")
+ expect(secondSafePush.stderr).toContain("skipped changes:")
expect(secondSafePush.stdout).toContain(`drop constraint ${schemaName}.users.users_email_check`)
expect(secondSafePush.stdout).toContain(`drop index ${schemaName}.users.users_email_idx`)
expect(secondSafePush.stdout).toContain(`replace column ${schemaName}.users.email (drop)`)
@@ -499,9 +520,9 @@ test("postgres cli migrate generate can split safe and destructive changes", asy
const safeGenerate = await runCli("migrate", "generate", "--config", config, "--name", "safe_phase")
expect(safeGenerate.exitCode).toBe(0)
- expect(safeGenerate.stdout).toContain("wrote 0001_safe_phase.sql")
- expect(safeGenerate.stdout).toContain(`drop column ${schemaName}.users.email`)
- expect(safeGenerate.stdout).toContain("skipped changes:")
+ expect(safeGenerate.stderr).toContain("wrote 0001_safe_phase.sql")
+ expect(safeGenerate.stderr).toContain(`drop column ${schemaName}.users.email`)
+ expect(safeGenerate.stderr).toContain("skipped changes:")
const safeSql = await readFile(join(workspace, "migrations", "0001_safe_phase.sql"), "utf8")
expect(safeSql).toContain(`alter table "${schemaName}"."users" add column "nickname" text;`)
@@ -509,7 +530,7 @@ test("postgres cli migrate generate can split safe and destructive changes", asy
const safeUp = await runCli("migrate", "up", "--config", config)
expect(safeUp.exitCode).toBe(0)
- expect(safeUp.stdout).toContain("applied 1 migration(s)")
+ expect(safeUp.stderr).toContain("applied 1 migration(s)")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -527,7 +548,7 @@ test("postgres cli migrate generate can split safe and destructive changes", asy
"destructive_phase"
)
expect(destructiveGenerate.exitCode).toBe(0)
- expect(destructiveGenerate.stdout).toContain("wrote 0002_destructive_phase.sql")
+ expect(destructiveGenerate.stderr).toContain("wrote 0002_destructive_phase.sql")
const destructiveSql = await readFile(join(workspace, "migrations", "0002_destructive_phase.sql"), "utf8")
expect(destructiveSql).toContain(`alter table "${schemaName}"."users" drop column "email";`)
@@ -535,7 +556,7 @@ test("postgres cli migrate generate can split safe and destructive changes", asy
const destructiveUp = await runCli("migrate", "up", "--config", config)
expect(destructiveUp.exitCode).toBe(0)
- expect(destructiveUp.stdout).toContain("applied 1 migration(s)")
+ expect(destructiveUp.stderr).toContain("applied 1 migration(s)")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -574,10 +595,10 @@ test("postgres cli applies pending migrations from alternate dirs and tables in
const migrateUp = await runCli("migrate", "up", "--config", config)
expect(migrateUp.exitCode).toBe(0)
- expect(migrateUp.stdout).toContain("applied 3 migration(s)")
- expect(migrateUp.stdout).toContain("0001_add_slug.sql")
- expect(migrateUp.stdout).toContain("0002_add_nickname.sql")
- expect(migrateUp.stdout).toContain("0010_add_title.sql")
+ expect(migrateUp.stderr).toContain("applied 3 migration(s)")
+ expect(migrateUp.stderr).toContain("0001_add_slug.sql")
+ expect(migrateUp.stderr).toContain("0002_add_nickname.sql")
+ expect(migrateUp.stderr).toContain("0010_add_title.sql")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -600,7 +621,7 @@ test("postgres cli applies pending migrations from alternate dirs and tables in
const secondUp = await runCli("migrate", "up", "--config", config)
expect(secondUp.exitCode).toBe(0)
- expect(secondUp.stdout).toContain("no pending migrations")
+ expect(secondUp.stderr).toContain("no pending migrations")
} finally {
await dropSchema(schemaName).catch(() => undefined)
await rm(workspace, { recursive: true, force: true })
@@ -638,7 +659,7 @@ alter table "${schemaName}"."users" drop column "nickname";
const migrateUp = await runCli("migrate", "up", "--config", config)
expect(migrateUp.exitCode).toBe(0)
- expect(migrateUp.stdout).toContain("applied 2 migration(s)")
+ expect(migrateUp.stderr).toContain("applied 2 migration(s)")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -654,7 +675,7 @@ alter table "${schemaName}"."users" drop column "nickname";
const migrateDown = await runCli("migrate", "down", "--config", config, "--steps", "1")
expect(migrateDown.exitCode).toBe(0)
- expect(migrateDown.stdout).toContain("rolled back 1 migration(s)")
+ expect(migrateDown.stderr).toContain("rolled back 1 migration(s)")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -669,7 +690,7 @@ alter table "${schemaName}"."users" drop column "nickname";
const repair = await runCli("migrate", "repair", "--config", config)
expect(repair.exitCode).toBe(0)
- expect(repair.stdout).toContain("repaired 1 migration record(s)")
+ expect(repair.stderr).toContain("repaired 1 migration record(s)")
const statusAfterRepair = await runCli("migrate", "status", "--config", config)
expect(statusAfterRepair.exitCode).toBe(0)
@@ -750,8 +771,8 @@ alter table "${schemaName}"."users" add column "slug" text;
expect(first.exitCode).toBe(0)
expect(second.exitCode).toBe(0)
- expect(`${first.stdout}\n${second.stdout}`).toContain("applied 1 migration(s)")
- expect(`${first.stdout}\n${second.stdout}`).toContain("no pending migrations")
+ expect(`${first.stdout}\n${first.stderr}\n${second.stdout}\n${second.stderr}`).toContain("applied 1 migration(s)")
+ expect(`${first.stdout}\n${first.stderr}\n${second.stdout}\n${second.stderr}`).toContain("no pending migrations")
const ledgerRows = await execPostgres<{
readonly count: number
@@ -777,7 +798,7 @@ test("postgres cli surfaces manual enum changes during push and migrate generate
import * as Schema from "effect/Schema"
import * as Pg from "effect-qb/postgres"
import { Query as Q, Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
const types = Pg.Schema.make("__SCHEMA__")
@@ -786,7 +807,7 @@ const status = types.enum("status", ["pending", "active"])
export const users = db.table("users", {
id: C.text(),
- status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(C.ddlType("\\"__SCHEMA__\\".\\"status\\""))
+ status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(Pg.Column.ddlType("\\"__SCHEMA__\\".\\"status\\""))
}).pipe(
Table.primaryKey((table) => table.id)
)
@@ -805,7 +826,7 @@ export { status }
import * as Schema from "effect/Schema"
import * as Pg from "effect-qb/postgres"
import { Query as Q, Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make(${JSON.stringify(schemaName)})
const types = Pg.Schema.make(${JSON.stringify(schemaName)})
@@ -814,7 +835,7 @@ const status = types.enum("status", ["pending"])
export const users = db.table("users", {
id: C.text(),
- status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(C.ddlType(${JSON.stringify(`"${schemaName}"."status"`)}))
+ status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(Pg.Column.ddlType(${JSON.stringify(`"${schemaName}"."status"`)}))
}).pipe(
Table.primaryKey((table) => table.id)
)
@@ -825,19 +846,19 @@ export { status }
const shrinkPush = await runCli("push", "--config", config)
expect(shrinkPush.exitCode).toBe(0)
expect(shrinkPush.stdout).toContain(`manual enum migration required for ${schemaName}.status`)
- expect(shrinkPush.stdout).toContain("no executable statements selected")
- expect(shrinkPush.stdout).toContain("skipped changes:")
+ expect(shrinkPush.stderr).toContain("no executable statements selected")
+ expect(shrinkPush.stderr).toContain("skipped changes:")
const shrinkGenerate = await runCli("migrate", "generate", "--config", config, "--name", "enum_shrink")
expect(shrinkGenerate.exitCode).toBe(0)
- expect(shrinkGenerate.stdout).toContain("no executable migration changes selected")
- expect(shrinkGenerate.stdout).toContain(`manual enum migration required for ${schemaName}.status`)
+ expect(shrinkGenerate.stderr).toContain("no executable migration changes selected")
+ expect(shrinkGenerate.stderr).toContain(`manual enum migration required for ${schemaName}.status`)
await writeFile(schemaFile(workspace), `
import * as Schema from "effect/Schema"
import * as Pg from "effect-qb/postgres"
import { Query as Q, Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make(${JSON.stringify(schemaName)})
const types = Pg.Schema.make(${JSON.stringify(schemaName)})
@@ -846,7 +867,7 @@ const status = types.enum("status", ["active", "pending"])
export const users = db.table("users", {
id: C.text(),
- status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(C.ddlType(${JSON.stringify(`"${schemaName}"."status"`)}))
+ status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(Pg.Column.ddlType(${JSON.stringify(`"${schemaName}"."status"`)}))
}).pipe(
Table.primaryKey((table) => table.id)
)
@@ -857,12 +878,12 @@ export { status }
const reorderPush = await runCli("push", "--config", config)
expect(reorderPush.exitCode).toBe(0)
expect(reorderPush.stdout).toContain(`manual enum migration required for ${schemaName}.status`)
- expect(reorderPush.stdout).toContain("no executable statements selected")
+ expect(reorderPush.stderr).toContain("no executable statements selected")
const reorderGenerate = await runCli("migrate", "generate", "--config", config, "--name", "enum_reorder")
expect(reorderGenerate.exitCode).toBe(0)
- expect(reorderGenerate.stdout).toContain("no executable migration changes selected")
- expect(reorderGenerate.stdout).toContain(`manual enum migration required for ${schemaName}.status`)
+ expect(reorderGenerate.stderr).toContain("no executable migration changes selected")
+ expect(reorderGenerate.stderr).toContain(`manual enum migration required for ${schemaName}.status`)
} finally {
await dropSchema(schemaName).catch(() => undefined)
await rm(workspace, { recursive: true, force: true })
@@ -904,7 +925,7 @@ test("postgres cli pull fails when filtered tables reference missing source targ
"schema.ts": `
import * as Pg from "effect-qb/postgres"
import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
@@ -965,7 +986,7 @@ test("postgres cli accepts --url overrides over the configured database url", as
const push = await runCli("push", "--config", config, "--url", postgresUrl)
expect(push.exitCode).toBe(0)
- expect(push.stdout).toContain("applied 2 statement(s)")
+ expect(push.stderr).toContain("applied 2 statement(s)")
const createdTables = await execPostgres(
`select tablename from pg_tables where schemaname = $1 order by tablename`,
@@ -979,7 +1000,7 @@ test("postgres cli accepts --url overrides over the configured database url", as
const pull = await runCli("pull", "--config", config, "--url", postgresUrl)
expect(pull.exitCode).toBe(0)
- expect(pull.stdout).toContain("updated 1 file(s)")
+ expect(pull.stderr).toContain("updated 1 file(s)")
const pulledSchema = await readSchema(workspace)
expect(pulledSchema).toContain(`name: Column.text().pipe(Column.nullable)`)
@@ -994,11 +1015,11 @@ test("postgres cli accepts --url overrides over the configured database url", as
const migrateGenerate = await runCli("migrate", "generate", "--config", config, "--url", postgresUrl, "--name", "override_path")
expect(migrateGenerate.exitCode).toBe(0)
- expect(migrateGenerate.stdout).toContain("wrote 0001_override_path.sql")
+ expect(migrateGenerate.stderr).toContain("wrote 0001_override_path.sql")
const migrateUp = await runCli("migrate", "up", "--config", config, "--url", postgresUrl)
expect(migrateUp.exitCode).toBe(0)
- expect(migrateUp.stdout).toContain("applied 1 migration(s)")
+ expect(migrateUp.stderr).toContain("applied 1 migration(s)")
expect(await listColumns(schemaName, "users")).toEqual([
{ column_name: "id" },
@@ -1017,7 +1038,7 @@ test("postgres cli honors source include exclude and table filters across multip
"tables/users.ts": `
import * as Pg from "effect-qb/postgres"
import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
@@ -1031,7 +1052,7 @@ export const users = db.table("users", {
"tables/orgs.ts": `
import * as Pg from "effect-qb/postgres"
import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
@@ -1045,7 +1066,7 @@ export const orgs = db.table("orgs", {
"tables/ignored.ts": `
import * as Pg from "effect-qb/postgres"
import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
@@ -1098,7 +1119,7 @@ export const audits = db.table("audits", {
const pull = await runCli("pull", "--config", config)
expect(pull.exitCode).toBe(0)
- expect(pull.stdout).toContain("updated 2 file(s)")
+ expect(pull.stderr).toContain("updated 2 file(s)")
expect(await readFile(join(workspace, "tables", "users.ts"), "utf8")).toContain("nickname")
expect(await readFile(join(workspace, "tables", "orgs.ts"), "utf8")).toContain("slug")
@@ -1106,7 +1127,7 @@ export const audits = db.table("audits", {
const secondPullDryRun = await runCli("pull", "--config", config, "--dry-run")
expect(secondPullDryRun.exitCode).toBe(0)
- expect(secondPullDryRun.stdout).toContain("schema definitions are already up to date")
+ expect(secondPullDryRun.stderr).toContain("schema definitions are already up to date")
} finally {
await dropSchema(schemaName).catch(() => undefined)
await rm(workspace, { recursive: true, force: true })
@@ -1118,7 +1139,7 @@ test("postgres cli round-trips enum, foreign-key, generated, identity, and rich
import * as Schema from "effect/Schema"
import * as Pg from "effect-qb/postgres"
import { Cast, ForeignKey, Function as F, Index, PrimaryKey, Query as Q, Table, Unique } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const tables = Pg.Schema.make("__SCHEMA__")
const types = Pg.Schema.make("__SCHEMA__")
@@ -1134,9 +1155,9 @@ const orgs = tables.table("orgs", {
)
const users = tables.table("users", {
- id: C.int().pipe(C.identityByDefault),
+ id: C.int().pipe(Pg.Column.identityByDefault),
orgId: C.uuid(),
- status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(C.ddlType("\\"__SCHEMA__\\".\\"status\\"")),
+ status: C.custom(Schema.String, Pg.Type.enum("status")).pipe(Pg.Column.ddlType("\\"__SCHEMA__\\".\\"status\\"")),
email: C.text(),
alias: C.text().pipe(C.nullable),
displayName: C.text().pipe(C.default(Cast.to(Q.literal("guest"), Q.type.text()))),
@@ -1208,7 +1229,7 @@ export { status, orgs, users }
const pull = await runCli("pull", "--config", config)
expect(pull.exitCode).toBe(0)
- expect(pull.stdout).toContain("updated 1 file(s)")
+ expect(pull.stderr).toContain("updated 1 file(s)")
const pulledSchema = await readSchema(workspace)
expect(pulledSchema).toContain(`const status = types.enum("status", ["pending", "active"])`)
@@ -1221,7 +1242,7 @@ export { status, orgs, users }
expect(pulledSchema).toContain(`deferrable: true`)
expect(pulledSchema).toContain(`initiallyDeferred: true`)
expect(pulledSchema).toContain(`users_email_lookup_idx`)
- expect(pulledSchema).toContain(`include: ["displayName"]`)
+ expect(pulledSchema).toContain(`Pg.Index.include((table) => table.displayName)`)
expect(pulledSchema).toContain(`order: "desc"`)
expect(pulledSchema).toContain(`nulls: "last"`)
expect(pulledSchema).toContain(`users_note_idx`)
@@ -1230,8 +1251,8 @@ export { status, orgs, users }
expect(pulledSchema).toContain(`Column.generated(`)
const secondPullDryRun = await runCli("pull", "--config", config, "--dry-run")
- expect(secondPullDryRun.exitCode).toBe(0)
- expect(secondPullDryRun.stdout).toContain("schema definitions are already up to date")
+ expect(secondPullDryRun).toMatchObject({ exitCode: 0 })
+ expect(secondPullDryRun.stderr).toContain("schema definitions are already up to date")
} finally {
await dropSchema(schemaName).catch(() => undefined)
await rm(workspace, { recursive: true, force: true })
@@ -1242,7 +1263,7 @@ test("postgres cli pulls supported checks and deferrable constraints into canoni
const { workspace, schemaName } = await makeSourceWorkspace(`
import * as Pg from "effect-qb/postgres"
import { PrimaryKey, Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
@@ -1272,7 +1293,7 @@ export const users = db.table("users", {
const pull = await runCli("pull", "--config", config)
expect(pull.exitCode).toBe(0)
- expect(pull.stdout).toContain("updated 1 file(s)")
+ expect(pull.stderr).toContain("updated 1 file(s)")
const pulledSchema = await readSchema(workspace)
expect(pulledSchema).toContain(`users_pkey`)
@@ -1280,7 +1301,7 @@ export const users = db.table("users", {
expect(pulledSchema).toContain(`initiallyDeferred: true`)
expect(pulledSchema).toContain(`users_email_key`)
expect(pulledSchema).toContain(`users_email_check`)
- expect(pulledSchema).toContain(`noInherit: true`)
+ expect(pulledSchema).toContain(`Pg.Check.noInherit`)
} finally {
await dropSchema(schemaName).catch(() => undefined)
@@ -1387,8 +1408,8 @@ test("postgres cli pull preserves non-default index collations", async () => {
test("postgres cli pulls composite foreign keys into canonical source definitions", async () => {
const { workspace, schemaName } = await makeSourceWorkspace(`
import * as Pg from "effect-qb/postgres"
-import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { PrimaryKey, Table } from "effect-qb"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
@@ -1432,17 +1453,15 @@ export { orgs, memberships }
const pull = await runCli("pull", "--config", config)
expect(pull.exitCode).toBe(0)
- expect(pull.stdout).toContain("updated 1 file(s)")
+ expect(pull.stderr).toContain("updated 1 file(s)")
const pulledSchema = await readSchema(workspace)
expect(pulledSchema).toContain(`memberships_org_fkey`)
- expect(pulledSchema).toContain(`columns: ["tenantId", "orgSlug"]`)
- expect(pulledSchema).toContain(`target: () => orgs`)
- expect(pulledSchema).toContain(`referencedColumns: ["tenantId", "slug"]`)
- expect(pulledSchema).toContain(`onDelete: "cascade"`)
- expect(pulledSchema).toContain(`onUpdate: "noAction"`)
- expect(pulledSchema).toContain(`deferrable: true`)
- expect(pulledSchema).toContain(`initiallyDeferred: true`)
+ expect(pulledSchema).toContain(`ForeignKey.make((table) => [table.tenantId, table.orgSlug], () => [orgs.tenantId, orgs.slug])`)
+ expect(pulledSchema).toContain(`ForeignKey.onDelete("cascade")`)
+ expect(pulledSchema).toContain(`ForeignKey.onUpdate("noAction")`)
+ expect(pulledSchema).toContain(`Pg.ForeignKey.deferrable`)
+ expect(pulledSchema).toContain(`Pg.ForeignKey.initiallyDeferred`)
} finally {
await dropSchema(schemaName).catch(() => undefined)
@@ -1454,7 +1473,7 @@ test("postgres cli pulls schema-builder table declarations into canonical source
const { workspace, schemaName } = await makeSourceWorkspace(`
import * as Pg from "effect-qb/postgres"
import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
const db = Pg.Schema.make("__SCHEMA__")
@@ -1484,7 +1503,7 @@ export const audits = db.table("audits", {
const pulledSchema = await readSchema(workspace)
expect(pulledSchema).toContain(`const audits = db.table(`)
- expect(pulledSchema).toContain(`actorName: Column.text().pipe(Column.nullable, Column.index({ name: "audits_actor_name_idx"`)
+ expect(pulledSchema).toContain(`actorName: Column.text().pipe(Column.nullable, Pg.Column.index({ name: "audits_actor_name_idx"`)
expect(pulledSchema).toContain(`audits_actor_name_idx`)
await assertIdempotentPullPush(config)
@@ -1522,13 +1541,13 @@ test("postgres cli pulls builtin postgres columns with dedicated constructors",
const pull = await runCli("pull", "--config", config)
expect(pull.exitCode).toBe(0)
- expect(pull.stdout).toContain("updated 1 file(s)")
+ expect(pull.stderr).toContain("updated 1 file(s)")
const pulledSchema = await readSchema(workspace)
- expect(pulledSchema).toContain(`payload: Column.jsonb(Schema.Unknown).pipe(Column.nullable)`)
+ expect(pulledSchema).toContain(`payload: Pg.Column.jsonb(Schema.Unknown).pipe(Column.nullable)`)
expect(pulledSchema).toContain(`Column.varchar(32)`)
expect(pulledSchema).toContain(`Column.char(1)`)
- expect(pulledSchema).toContain(`Column.text().pipe(Column.array(), Column.nullable)`)
+ expect(pulledSchema).toContain(`Column.text().pipe(Pg.Column.array(), Column.nullable)`)
expect(pulledSchema).toContain(`Column.number({ precision: 10, scale: 4 }).pipe(Column.nullable)`)
expect(pulledSchema).toContain(`Column.int8()`)
expect(pulledSchema).toContain(`Column.timestamptz()`)
@@ -1551,7 +1570,7 @@ test("postgres cli pulls class table declarations into canonical source definiti
const { workspace, schemaName } = await makeSourceWorkspace(`
import * as Pg from "effect-qb/postgres"
import { Table } from "effect-qb"
-import { Column as C } from "effect-qb/postgres"
+import { Column as C } from "effect-qb"
export class Sessions extends Table.Class("sessions", "__SCHEMA__")({
id: C.uuid().pipe(C.primaryKey),
@@ -1581,7 +1600,7 @@ export class Sessions extends Table.Class("sessions", "__SCHEMA__")({
expect(pulledSchema).toContain(`class Sessions extends Table.Class("sessions", "${schemaName}")({`)
expect(pulledSchema).toContain(`lastSeenAt: Column.timestamp().pipe(`)
expect(pulledSchema).toContain(`Column.timestamp().pipe(Column.nullable)`)
- expect(pulledSchema).toContain(`email: Column.text().pipe(Column.index({ name: "sessions_email_idx"`)
+ expect(pulledSchema).toContain(`email: Column.text().pipe(Pg.Column.index({ name: "sessions_email_idx"`)
expect(pulledSchema).toContain(`sessions_email_idx`)
} finally {
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 00000000..5af02114
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": [
+ "packages/*/src/**/*.ts"
+ ]
+}