npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

better-effect-kysely

v0.1.1

Published

Effect-like server integration between better-effect and Kysely.

Readme

better-effect-kysely

better-effect-kysely is a small, server-side integration between better-effect and Kysely. Kysely remains the type-safe SQL query builder, compiler, executor and dialect boundary. This package is not an ORM, repository framework or replacement for Kysely.

What it is

The integration turns Kysely's Promise-based query terminals into lazy better-effect Operations while preserving the native Kysely instance, builders, plugins, logging and driver behavior:

Kysely builder
    ↓ .$call(KyselyEffect.execute)
Kysely operation
    ↓ Runtime executes one lazy Promise
better-result Result
    ↓
Effect Program with typed Service requirements

The package adds no driver. Install the dialect and driver your application chooses. The release validation uses Bun's built-in SQLite adapter and PGlite; that evidence validates the Kysely boundary, but does not certify every PostgreSQL, MySQL or SQLite driver combination.

Why this design

Kysely builders are deliberately not Effects. They are immutable query expressions with native Promise terminals, so $call is the explicit place where a Program crosses into asynchronous execution. Keeping that boundary small preserves Kysely's receiver, private state, plugins, logging and dialect behavior. The package therefore avoids Proxy wrappers, prototype patches, module augmentation and a second query instruction tree.

Installation

Install the integration, its peers and one Kysely dialect driver:

bun add better-effect-kysely better-effect better-result kysely
# Add the driver selected by your dialect, for example:
bun add better-sqlite3

better-effect-kysely has no runtime dependencies. Its peer requirements are:

| Peer | Supported range | | --------------- | ------------------ | | better-effect | >=0.14.0 <0.15.0 | | better-result | ^3.0.0 | | kysely | >=0.29.5 <0.30.0 | | TypeScript | >=6.0.0 |

Drivers are application dependencies, not package dependencies. The package entrypoint can be imported without installing a database driver.

Define a database Service

KyselyEffect.service creates a yieldable Service token for one database schema. The value resolved from the token is the original Kysely<DB> object; there is no wrapper, clone, subclass, Proxy, prototype patch or module augmentation.

import { KyselyEffect } from 'better-effect-kysely'

interface AppDatabase {
  users: {
    id: number
    email: string
  }
}

const Database = KyselyEffect.service<AppDatabase>()('@app/Database')

Use the token in a better-effect Program:

import { Effect } from 'better-effect'
import { Result } from 'better-result'

const listUsers = Effect.fn(async function* () {
  const database = yield* Database
  const users = yield* database
    .selectFrom('users')
    .select(['id', 'email'])
    .$call(KyselyEffect.execute)

  return Result.ok(users)
})

The Database token retains the exact schema inference. A query is not itself yieldable: use one of the explicit $call terminals described below.

Owned and borrowed lifecycle

Choose ownership when defining the Layer. The factory is lazy and runs only when the Runtime first resolves the provider:

| Origin of the Kysely instance | API | Who calls destroy()? | | -------------------------------------------------------- | ---------------------------- | ---------------------------- | | The Layer creates and owns the instance | Database.scoped(factory) | Runtime root during shutdown | | The factory borrows a pool/driver owned by another Layer | Database.borrowed(factory) | The pool/driver owner | | The caller already has the instance | Database.succeed(database) | The caller |

Use scoped when the Layer creates the database and must close it. Use borrowed when Kysely is a facade over a pool or driver owned by another Service, such as a shared pool also used by an authentication subsystem. Use succeed for an already-created caller-owned value. Ownership is explicit and is never inferred from the Kysely type.

import { Runtime } from 'better-effect'

const ownedRuntime = await Runtime.make(Database.scoped(() => database))
await ownedRuntime.dispose() // calls database.destroy()

const borrowedRuntime = await Runtime.make(Database.succeed(database))
await borrowedRuntime.dispose() // leaves database usable
await database.destroy() // caller cleanup

Contextual factories can resolve other Services with yield*, in either a sync or async generator. Their requirements become Layer.Required:

class DatabasePool extends Service<DatabasePool>()('@app/DatabasePool') {
  constructor(readonly raw: Pool) {
    super()
  }
}

const DatabaseLive = Database.borrowed(function* () {
  const pool = yield* DatabasePool
  return new Kysely<AppDatabase>({ dialect: makeDialect(pool.raw) })
})

scoped registers database.destroy() exactly once after child executions finish. borrowed and succeed never register a destroy finalizer, so a shared pool remains open until its owning Layer releases it. The native Kysely instance and its private state remain untouched.

There is no legacy ownership alias. Choose scoped, borrowed or succeed so ownership is explicit at the call site.

Execute queries

The public terminal namespace is frozen and its functions are intended for Kysely's native $call method:

| Terminal | Success value | | ------------------------------------------------------------- | ------------------------------------------------ | | KyselyEffect.execute | Complete array returned by query.execute() | | KyselyEffect.executeWith(options) | Same, with Kysely execution options | | KyselyEffect.executeTakeFirst | First row or undefined | | KyselyEffect.executeTakeFirstWith(options) | First row or undefined, configured | | KyselyEffect.executeTakeFirstOrFail(makeError) | First row, or the caller's error for undefined | | KyselyEffect.executeTakeFirstOrFailWith(options, makeError) | Configured first-row-or-fail |

All terminals are lazy, invoke the native terminal once and preserve the native receiver and result reference:

const users = yield * database.selectFrom('users').selectAll().$call(KyselyEffect.execute)

const optionalUser =
  yield *
  database
    .selectFrom('users')
    .selectAll()
    .where('id', '=', userId)
    .$call(KyselyEffect.executeTakeFirst)

const user =
  yield *
  database
    .selectFrom('users')
    .selectAll()
    .where('id', '=', userId)
    .$call(KyselyEffect.executeTakeFirstOrFail(() => new UserNotFound(userId)))

The executeTakeFirstOrFail helpers map only strict undefined to makeError. A nullable row value remains a successful row.

DDL and mutations use the same terminal. returningAll() retains the native dialect result:

yield *
  database.schema
    .createTable('users')
    .addColumn('id', 'integer', (column) => column.primaryKey())
    .addColumn('email', 'text', (column) => column.notNull())
    .$call(KyselyEffect.execute)

const inserted =
  yield *
  database
    .insertInto('users')
    .values({ id: 1, email: '[email protected]' })
    .returningAll()
    .$call(KyselyEffect.execute)

KyselyExecutionOptions exposes Kysely's inflightQueryAbortStrategy (ignore query, cancel query or kill session). It intentionally has no signal: the active Runtime supplies one fresh linked signal for each execution.

Read one row

executeTakeFirst returns A | undefined without inventing an application error. Use executeTakeFirstOrFail when absence is a domain failure:

class UserNotFound extends Error {
  constructor(readonly userId: number) {
    super(`User ${userId} was not found`)
  }
}

const user =
  yield *
  database
    .selectFrom('users')
    .selectAll()
    .where('id', '=', userId)
    .$call(KyselyEffect.executeTakeFirstOrFail(() => new UserNotFound(userId)))

This is different from Kysely's executeTakeFirstOrThrow: the failure is an Effect/Result error and can be handled by the caller without turning the absence into an uncaught defect.

Raw and compiled queries

KyselyEffect.executeQuery(database, query, options?) accepts a native RawBuilder, a structural Compilable or a CompiledQuery and returns the complete Kysely QueryResult, including rows and dialect metadata:

import { sql } from 'kysely'

const raw =
  yield * KyselyEffect.executeQuery(database, sql<{ value: number }>`select ${1} as value`)

const compiled = database
  .selectFrom('users')
  .select(['id', 'email'])
  .where('id', '=', userId)
  .compile()
const result = yield * KyselyEffect.executeQuery(database, compiled)

Genuine RawBuilders use Kysely's native RawBuilder executor, so raw-builder plugins and result transformation still run once. Compiled queries retain their original SQL and parameters; the bridge does not compile them again. The overload also accepts a native Transaction<DB> as the executor.

Transactions

KyselyEffect.transaction takes a Kysely instance and a lazy Program factory. Kysely begins the native transaction before invoking the factory, and the factory receives the native Transaction<DB> instance. The transaction does not replace the outer Database Service and does not create a nested Runtime.

const createUser = Effect.fn(async function* () {
  const database = yield* Database

  const user = yield* KyselyEffect.transaction(database, (transaction) =>
    Effect.fn(async function* () {
      const created = yield* transaction
        .insertInto('users')
        .values({ id: 1, email: '[email protected]' })
        .returningAll()
        .$call(KyselyEffect.executeTakeFirstOrFail(() => new Error('insert returned no row')))

      return Result.ok(created)
    })
  )

  return Result.ok(user)
})

The body result determines the native transaction outcome:

| Body outcome | Native action | Caller observes | | ------------------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------- | | Result.ok(value) | Commit | The original value | | Result.err(error) | Roll back | The same typed error if rollback succeeds; otherwise a KyselyTransactionError with .bodyFailure | | Thrown/rejected defect | Roll back | The original defect, or an aggregate with cleanup failure | | Aborted Runtime signal | Roll back | The original abort reason, or an aggregate with cleanup failure | | Native begin/commit/rollback failure | Best-effort cleanup | KyselyTransactionError when no more primary failure exists |

Every Result.err from the body rolls back, including a query operation error. When rollback succeeds, the exact typed error is restored. If rollback itself fails, the result is a KyselyTransactionError and the original body failure is retained as its non-enumerable .bodyFailure. Defects and abort reasons are re-thrown (with body-first aggregate composition when cleanup also fails). The body may yield other Services from the existing Runtime. Optional KyselyTransactionOptions forwards Kysely's native isolationLevel and accessMode only.

Cancellation is checked before transaction creation, after the body succeeds and immediately before returning success. The public Kysely callback API still has an unavoidable final check-to-commit race. There is no automatic retry, savepoint or controlled-transaction policy.

Cancellation

Runtime cancellation is cooperative. Pass an AbortSignal to the Runtime execution; the query operation receives the linked signal through Kysely's native options:

const result = await runtime.run(listUsers, { signal: request.signal })

cancel query and kill session are driver/dialect capabilities, not universal guarantees. ignore query stops waiting according to Kysely's policy but does not necessarily stop server-side work. A write may have been applied before cancellation is observed. Inspect KyselyQueryError.cause when an application needs driver-specific cancellation details.

Errors and security

The bridge exposes two safe boundary errors:

  • KyselyQueryError for Promise/query failures;
  • KyselyTransactionError for native transaction or cleanup failures.

The original cause is available in memory as .cause, but is non-enumerable and excluded from toJSON(). Messages and serialized fields do not include SQL, parameters, credentials or driver-specific details. Applications should map cause deliberately at a trusted diagnostic boundary rather than adding it to HTTP responses or structured logs by default.

Typed domain errors returned by executeTakeFirstOrFail or a transaction body remain application errors. When cleanup succeeds, they remain the primary failure. A typed body failure plus rollback failure is represented by KyselyTransactionError.bodyFailure; defects and abort reasons are composed body-first with the native cleanup failure. A successful operation exposes a cleanup failure instead of its value.

Multiple databases

Give separate database schemas distinct literal tags, then compose their Layers. The resolved values retain independent Kysely identities:

const PrimaryDatabase = KyselyEffect.service<PrimarySchema>()('@app/PrimaryDatabase')
const AnalyticsDatabase = KyselyEffect.service<AnalyticsSchema>()('@app/AnalyticsDatabase')

const AppLive = Layer.merge(
  PrimaryDatabase.scoped(createPrimaryDatabase),
  AnalyticsDatabase.scoped(createAnalyticsDatabase)
)

A duplicate tag is rejected by Layer composition. Use distinct tags even when two schemas happen to have the same shape.

Testing

Use a borrowed in-memory database when the test owns setup and cleanup:

const database = makeTestDatabase()
const runtime = await Runtime.make(Database.succeed(database))

try {
  const result = await runtime.run(listUsers)
  // Assert Result and native Kysely rows here.
} finally {
  await runtime.dispose()
  await database.destroy()
}

Prefer a real dialect for integration coverage. Use Layer.override to replace only the database Service when testing a larger application composition. The package's own validation covers Bun SQLite and PGlite, while PostgreSQL, MySQL and SQLite Kysely type surfaces are checked without external servers. Do not mock every Kysely builder when the behavior under test is the native query compiler or driver boundary.

Compatibility

The 0.1.x line is tested with:

  • the latest Bun release and the current Node.js LTS;
  • TypeScript 6.0 or newer, with the repository using TypeScript 7.x;
  • Kysely 0.29.5 (the minimum and current tested version in this release);
  • Bun's built-in SQLite adapter and PGlite 0.5.8 for real database tests;
  • better-sqlite3 12.4.1 in the external Node.js consumer cell (the Bun consumer cell uses Bun's built-in adapter because better-sqlite3 is not supported by Bun).

The package is dialect-agnostic at runtime and has no bundled driver. The validation matrix does not mean that every external driver, server version or cancellation mechanism has identical behavior.

Non-goals and roadmap

The 0.1.x integration intentionally does not provide:

  • migrations or schema management beyond using Kysely's own schema builder;
  • streaming or cursor abstractions;
  • controlled transactions, savepoints or automatic retries;
  • OpenTelemetry/RuntimeObserver integration;
  • schema codecs or runtime result validation;
  • a repository, ORM or data-access framework;
  • directly yieldable Kysely builders.

Use native Kysely APIs or a focused application adapter for those concerns.

API reference

| Export | Kind | Contract | | --------------------------- | ----------------- | -------------------------------------------------------------------------- | | KyselyEffect | runtime namespace | Service factory, lazy $call terminals, executeQuery and transaction | | KyselyOperation | type | better-effect Operation carrying a value, error and Service requirements | | KyselyExecutionOptions | type | Kysely query abort-strategy options, without signal | | KyselyTransactionOptions | type | Native isolationLevel and accessMode options | | KyselyQueryOperation | type | Supported query boundary names for diagnostics | | KyselyServiceInstance | type | Branded native Kysely<DB> instance contract | | KyselyServiceToken | type | Service token for a tagged Kysely schema | | KyselyService | type | Native Kysely<DB> service contract | | KyselyExecutable | type | Structural native execute terminal | | KyselyTakeFirstExecutable | type | Structural native first-row terminal | | KyselyQueryError | runtime class | Safe query failure with an in-memory cause | | KyselyTransactionError | runtime class | Safe transaction/cleanup failure with an in-memory cause |

Runtime helpers are namespaced under KyselyEffect so the package has one explicit integration boundary. Type aliases are also exported at the root for consumer signatures and are mirrored under KyselyEffect where useful.

See the executable examples in examples/ and the deeper integration guide at /docs/kysely.

License

MIT