Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/clickhouse-http-and-spans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@maple-dev/effect-orm": minor
---

A ClickHouse client for Cloudflare Workers and any `fetch` runtime, and OpenTelemetry-shaped spans for every statement.

- `@maple-dev/effect-orm/clickhouse-http` is a ClickHouse `SqlClient` over Effect's `HttpClient`, so `Database` runs where `@effect/sql-clickhouse` (Node only) cannot. Interrupting a statement aborts its request and sends `KILL QUERY` for its `query_id`; failures carry the same `SqlError` reasons as Effect's driver; credentials travel in a header; `asCommand`, `withQueryId` and `withSettings` work as there.
- `Database` opens one client span per statement, named and attributed per the OpenTelemetry database conventions: `db.system.name`, `db.operation.name`, `db.collection.name`, `db.query.summary`, `db.response.returned_rows` and `error.type`. `withQuerySummary(name)` names the statements an effect runs.
- `CompiledQuery.collection` is the table a statement targets: a SELECT's `FROM` table, or the table a write changes.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ regressions live in [`src/docs-examples.test.ts`](./src/docs-examples.test.ts).
| `@maple-dev/effect-orm/schema` | Migration tooling over `table` values: DDL rendering, snapshots, the schema diff. Pure. |
| `@maple-dev/effect-orm/kit`, `/migrate` | `effect-orm generate` and `check`; applying migrations through a driver you provide. See [Schema and migrations](./docs/migrations.md). |
| `@maple-dev/effect-orm/database` | `Database` over your `SqlClient`: `run`, `execute`, `transaction` with retry. |
| `@maple-dev/effect-orm/clickhouse-http` | A ClickHouse `SqlClient` over `fetch`, for Cloudflare Workers and any runtime with `fetch`; give it to `Database`. |

## Extending with custom functions

Expand Down
25 changes: 25 additions & 0 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,31 @@ fails at BEGIN today: through the query path with a syntax error, and through `a
`NOT_IMPLEMENTED`. The full measurements are in
[`design/transactions.md`](../design/transactions.md).

## Tracing

Each statement `Database` runs is one client span, named and attributed the way the
[OpenTelemetry database conventions](https://opentelemetry.io/docs/specs/semconv/database/database-spans/)
describe. A compiled query knows its operation and table, so the span needs no SQL parsing:

| Attribute | Value |
| --------------------------- | -------------------------------------------------------------------- |
| `db.system.name` | `clickhouse`, or `postgresql` |
| `db.operation.name` | `SELECT`, `INSERT`, `UPDATE` or `DELETE` |
| `db.collection.name` | The `FROM` or written table; absent over a subquery, union or CTE |
| `db.query.summary` | `SELECT events`, or the name given with `withQuerySummary` |
| `db.response.returned_rows` | The rows it returned |
| `error.type` | On failure: the `DatabaseError` reason, such as `SqlSyntaxError` |

The span is named after the summary, so a trace reads `SELECT events` rather than `sql.execute`.
Effect's own `sql.execute` span runs inside it and carries the query text. To give a group of
statements a stable name of your own, wrap them:

```ts
Db.run(query, params).pipe(Db.withQuerySummary("dashboard.revenue_by_day"))
```

The table is also on the compiled query, as `compiled.collection`.

## Writes

The builder compiles SELECTs, [INSERTs](./inserts.md) and
Expand Down
2 changes: 2 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,8 @@ placeholders, double-quoted identifiers.
| `run`, `status`, `verify`, `baseline`, `MigrationDriver` and the migrate errors | `/migrate` |
| `Database`, `run`, `sql`, `query`, `execute`, `transaction`, `requireTransaction`, `retryContention`, `Transaction` | `/database` |
| `DatabaseError`, `TransactionCommitFailed`, `TransactionRollbackFailed` and the other transaction errors | `/database` |
| `withQuerySummary`, `QuerySummary` — the `db.query.summary` of the statements an effect runs, for spans | `/database` |
| `ClickhouseHttpClient`, `layer`, `make`, `ClickhouseHttpClientConfig` — a ClickHouse `SqlClient` over `fetch`, for Workers | `/clickhouse-http` |
| `defineConfig`, `generate`, `check`, `loadSchema`, `readMigrations`, `analyze`, `KitError` | `/kit` |
| `defineSuite`, `query`, `caseFromCompiled`, `runSuite`, `compareRuns`, `compareBudgets` | `/benchmark` |
| `Suite`, `RunOutput`, `BenchmarkError` and benchmark contracts | `/benchmark` |
Expand Down
57 changes: 54 additions & 3 deletions docs/running-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,62 @@ SQL failures as `SqlError`.
not validate or escape arbitrary SQL; keep untrusted values in `CH.param` bindings. Leave result
name transforms disabled so aliases still match the compiled decoder.

`@effect/sql-clickhouse` wraps the official Node.js client. For Workers or other fetch-based
runtimes, use a runtime-compatible adapter such as `@clickhouse/client-web` with an Effect
integration. Keep database credentials on your server.
`@effect/sql-clickhouse` wraps the official Node.js client, so it does not run in Cloudflare
Workers, Deno or a browser. For those, and anywhere `fetch` exists, use
[`@maple-dev/effect-orm/clickhouse-http`](#on-workers-and-other-fetch-runtimes) below. Keep
database credentials on your server.
See the [Effect ClickHouse driver source](https://github.com/Effect-TS/effect/blob/main/packages/sql/clickhouse/src/ClickhouseClient.ts).

### On Workers and other fetch runtimes

`@maple-dev/effect-orm/clickhouse-http` is a ClickHouse `SqlClient` over Effect's `HttpClient`.
Provide `FetchHttpClient.layer` and it runs wherever `fetch` does; give it to `Database` like
any other client:

```ts
import { Effect, Layer, Redacted } from "effect"
import { FetchHttpClient } from "effect/http"
import * as SqlClient from "effect/sql/SqlClient"
import * as CH from "@maple-dev/effect-orm/clickhouse"
import * as ClickhouseHttp from "@maple-dev/effect-orm/clickhouse-http"
import * as Db from "@maple-dev/effect-orm/database"

const ClickHouse = ClickhouseHttp.layer({
url: "https://abc.clickhouse.cloud:8443",
username: "reader",
password: Redacted.make("…"),
database: "analytics",
}).pipe(Layer.provide(FetchHttpClient.layer))

const DatabaseLive = Layer.effect(
Db.Database,
Effect.gen(function* () {
return Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: CH.clickhouseDialect })
}),
).pipe(Layer.provideMerge(ClickHouse))
```

What it does for you:

- **Interrupting cancels the query.** A timeout or an interrupted fiber aborts the request and
sends `KILL QUERY` for its `query_id`: a closed connection alone does not stop a query
ClickHouse is still computing. That holds while the body is read too, and for a `.stream`
that ends before its body does (interrupted, failed, or stopped early with `Stream.take`).
Queries also send `cancel_http_readonly_queries_on_client_close`.
- **Failures carry Effect's `SqlError` reasons** (`SqlSyntaxError`, `AuthenticationError`,
`StatementTimeoutError`, …), read from ClickHouse's exception code, the same as
`@effect/sql-clickhouse`.
- **Credentials travel in a header,** never the URL; the URL may not contain them. A path in the
URL is kept, for a ClickHouse behind a proxy prefix.
- **Spans name the peer:** `server.address`, `server.port` and `peer.service` (`clickhouse`;
override it with `spanAttributes`), so a service map draws the edge to the database.
- `asCommand` runs DDL and writes, `withQueryId` sets the `query_id`, and `withSettings` adds
ClickHouse settings over the configured ones. Statements bind `sql` template values on the
server, as `{pN: Type}` placeholders.

It opens no connection at startup, so a Worker's first request pays nothing extra. Its spans
are `Database`'s, described in [Running statements: tracing](./database.md#tracing).

For a client that implements Effect's `SqlClient`, the opt-in
[`@maple-dev/effect-orm/database`](./database.md) entry point does this loop for you
(`Db.run(compiled)`), and adds transactions on Postgres.
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
"import": "./dist/clickhouse.mjs",
"default": "./dist/clickhouse.mjs"
},
"./clickhouse-http": {
"types": "./dist/clickhouse-http.d.mts",
"import": "./dist/clickhouse-http.mjs",
"default": "./dist/clickhouse-http.mjs"
},
"./expr": {
"types": "./dist/expr.d.mts",
"import": "./dist/expr.mjs",
Expand Down Expand Up @@ -132,4 +137,4 @@
"patchedDependencies": {
"@effect/vitest@4.0.0": "patches/@effect%2Fvitest@4.0.0.patch"
}
}
}
22 changes: 20 additions & 2 deletions src/ch/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,16 @@ interface CompiledQueryBase<Output> {
* meant: the SQL may even run, with ClickHouse quoting and inlined params.
*/
readonly dialect?: string
/**
* The table the statement targets: a SELECT's `FROM` table, or the table an
* INSERT, UPDATE or DELETE writes. Absent when there is no single table, as
* for a `FROM` over a subquery, a union or a CTE, and for handwritten SQL.
*
* What a tracer reports as `db.collection.name`, and with `kind` the
* statement's `db.query.summary` (`SELECT events`), so a span names its
* table without parsing SQL.
*/
readonly collection?: string
/** Runtime decode of raw query results. Queries built from handwritten SQL
* should provide a row schema so schema drift is caught before consumers
* read fields from `Record<string, unknown>`. Without a schema this is an
Expand Down Expand Up @@ -413,6 +423,7 @@ const makeCompiledQuery = <Output, Route extends string | undefined>(
kind: CompiledQueryBase<Output>["kind"] = "select",
returning?: ReadonlyArray<string>,
crossTenantDeclared = false,
collection?: string,
): CompiledQuery<Output, Route> => {
let cachedDecodeRow: ((row: unknown) => Effect.Effect<Output, unknown, never>) | undefined
let decoderBuilt = false
Expand Down Expand Up @@ -489,6 +500,7 @@ const makeCompiledQuery = <Output, Route extends string | undefined>(
rowSchemaMismatch,
...(rawSql !== undefined ? { rawSql } : undefined),
...(dialect !== undefined ? { dialect } : undefined),
...(collection !== undefined ? { collection } : undefined),
...(!(route === undefined) ? { route } : undefined),
decodeRows,
encodeRows,
Expand Down Expand Up @@ -997,6 +1009,10 @@ function compileInner<
"select",
undefined,
state.crossTenant === true,
// A table, not a CTE of the same name and not a subquery or union.
state.fromQuery || state.fromUnion || visibleCtes.some((c) => c.name === state.tableName)
? undefined
: state.tableName,
),
tenantScope === "single-tenant" ? scope.bound : undefined,
)
Expand Down Expand Up @@ -1697,6 +1713,7 @@ const writeCompiledQuery = (
tenantBound: string | undefined,
returning: ReturnType<typeof returningOf>,
crossTenant: boolean | undefined,
collection: string,
): CompiledQuery<any, undefined> => {
const returnedSchema = returning !== undefined && "schema" in returning.derived ? returning.derived.schema : undefined
return withTenantBound(
Expand All @@ -1714,6 +1731,7 @@ const writeCompiledQuery = (
kind,
returning?.aliases,
crossTenant === true,
collection,
),
crossTenant === true ? undefined : tenantBound,
)
Expand Down Expand Up @@ -1948,7 +1966,7 @@ function compileInsert(insert: CHInsert<any, any, any, any>, params: Record<stri
},
reads,
)
return writeCompiledQuery(rendered, "insert", own.scope, own.bound, returning, insert._state.crossTenant)
return writeCompiledQuery(rendered, "insert", own.scope, own.bound, returning, insert._state.crossTenant, table.name)
}

/** The VALUES rows' columns, in table order, after checking every key. */
Expand Down Expand Up @@ -2082,5 +2100,5 @@ function compileUpdateOrDelete(
: `UPDATE ${target} SET ${assignments.join(", ")}`
const rendered = renderParams(`${head}${whereSql}${returning?.sql ?? ""}${settings}`, values, dialect)
const scope = withReads({ scope: tenantScope, bound: tenantScope === "single-tenant" ? tenantBound : undefined }, reads)
return writeCompiledQuery(rendered, kind, scope.scope, scope.bound, returning, state.crossTenant)
return writeCompiledQuery(rendered, kind, scope.scope, scope.bound, returning, state.crossTenant, table.name)
}
14 changes: 14 additions & 0 deletions src/clickhouse-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// @maple-dev/effect-orm/clickhouse-http
//
// A ClickHouse `SqlClient` over Effect's `HttpClient`: runs wherever `fetch`
// does, Cloudflare Workers included, where `@effect/sql-clickhouse` (Node only)
// cannot. Give it to `Database` like any other `SqlClient`. See
// docs/running-queries.md.

export {
ClickhouseHttpClient,
layer,
make,
type ClickhouseHttpClientConfig,
type ClickhouseSetting,
} from "./clickhouse-http/client"
Loading
Loading