1- <!-- docs: sync from coderbuzz/codex@9a7a8a5 -->
1+ <!-- docs: sync from coderbuzz/codex@b37bd48 -->
22
33# @coderbuzz/sql : AI Expert Knowledge Reference
44
5- ** Package:** ` @coderbuzz/sql ` v0.1.3 \
5+ ** Package:** ` @coderbuzz/sql ` v0.8.1 \
66** Purpose:** Comprehensive reference for AI agents generating application code
77with the ` @coderbuzz/sql ` library.\
88** Distribution:** ESM only (` dist/ ` folder). No source ` .ts ` files in the
@@ -53,44 +53,32 @@ import { mssql } from "@coderbuzz/sql/mssql";
5353import { ch } from " @coderbuzz/sql/clickhouse" ;
5454```
5555
56+ ` @coderbuzz/sql/sqlite ` resolves by export condition: ` bun ` → ` bun:sqlite ` ,
57+ ` deno ` → ` @db/sqlite ` , ` default ` (Node.js) → ` better-sqlite3 ` , falling back to
58+ the built-in ` node:sqlite ` (Node 22+) when ` better-sqlite3 ` is not installed.
59+
5660### 2.2 Root package: shared helpers and types
5761
5862``` ts
5963import {
60- and ,
6164 avg ,
6265 type BatchOptions ,
6366 // Types
6467 type CompiledQuery ,
6568 // Aggregate helpers
6669 count ,
6770 DeleteQuery ,
68- // Expression helpers (also in each dialect namespace)
69- eq ,
7071 // Expression factory
7172 expr ,
72- gt ,
73- gte ,
74- ilike ,
7573 type InferRow ,
7674 type InferSelect ,
77- inList ,
7875 InsertBatcher ,
7976 type InsertOptions ,
8077 InsertQuery ,
81- isNotNull ,
82- isNull ,
83- like ,
84- lt ,
85- lte ,
8678 max ,
8779 type Middleware ,
8880 min ,
89- ne ,
90- not ,
9181 type OnConflictClause ,
92- or ,
93- raw ,
9482 SelectQuery ,
9583 // Classes (for advanced use)
9684 Sql ,
@@ -129,6 +117,11 @@ import {
129117} from " @coderbuzz/sql" ;
130118```
131119
120+ The expression helpers (` eq ` , ` ne ` , ` gt ` , ` gte ` , ` lt ` , ` lte ` , ` like ` , ` ilike ` ,
121+ ` inList ` , ` isNull ` , ` isNotNull ` , ` and ` , ` or ` , ` not ` , ` raw ` ) are ** not**
122+ exported from the root package. Use them from a dialect namespace:
123+ ` pg.eq(...) ` , ` sqlite.and(...) ` , and so on.
124+
132125### 2.2b Decimal subpath (no engine, no dialect)
133126
134127``` ts
@@ -315,18 +308,37 @@ await db.execute(users.dropTable()); // "DROP TABLE IF EXISTS users;"
315308import { introspect } from " @coderbuzz/sql/dist/migration/introspect" ;
316309import { diff } from " @coderbuzz/sql/dist/migration/diff" ;
317310import { applyDiff } from " @coderbuzz/sql/dist/migration/apply" ;
318- import { sqliteCompiler } from " @coderbuzz/sql/dist/dialects/sqlite" ;
319311
320312const live = await introspect (db ); // query live schema
321313const diffs = diff (live , [usersV2 .toAst ()]); // compute diffs
322- const stmts = applyDiff (diffs , sqliteCompiler ); // ALTER TABLE statements
314+ const stmts = applyDiff (diffs , db ); // ALTER TABLE statements; a compiler also works
323315
324316for (const stmt of stmts ) {
325317 await db .execute (stmt );
326318}
327319```
328320
329- ` applyDiff() ` supports: RENAME COLUMN (all dialects), ADD COLUMN (all dialects),
321+ > ** Not importable yet.** ` introspect ` , ` diff ` and ` applyDiff ` live in
322+ > ` src/migration/ ` but are not a tsup entry and not in the ` exports ` map, so
323+ > the ` @coderbuzz/sql/dist/migration/* ` paths above do not resolve in the
324+ > published package. Do not generate code that imports them until a
325+ > ` ./migration ` subpath exists.
326+
327+ Signatures: ` introspect(db: Sql<any>): Promise<CreateTableNode[]> ` (SQLite,
328+ PostgreSQL, MySQL, MSSQL), ` diff(live, target): TableDiff[] ` ,
329+ ` applyDiff(diffs, target: BaseCompiler | engine, options?: { allowDestructive?: boolean }): string[] ` .
330+ ` DROP COLUMN ` is omitted (with a ` console.warn ` ) unless
331+ ` { allowDestructive: true } ` is passed.
332+
333+ On PostgreSQL, run the statements inside ` db.transaction() ` (DDL is
334+ transactional, so a failure leaves the schema untouched) and wrap the whole
335+ thing in ` db.withAdvisoryLock(key, fn) ` so two instances of a rolling deploy
336+ do not migrate at once. The version ledger and file loading belong in the
337+ application.
338+
339+ ` applyDiff() ` supports: RENAME COLUMN (emitted for all dialects as
340+ ` ALTER TABLE ... RENAME COLUMN ` , which SQL Server rejects: it needs
341+ ` sp_rename ` ), ADD COLUMN (all dialects),
330342DROP COLUMN (PG/MySQL/MSSQL), ALTER COLUMN (PG/MySQL/MSSQL). SQLite skips
331343DROP/ALTER with a ` console.warn ` .
332344
@@ -493,7 +505,7 @@ const { sql, params } = users.from(db)
493505 .fields (" id" , " email" )
494506 .where ({ active: true })
495507 .toSQL ();
496- // sql: 'SELECT "id", " email" FROM users WHERE active = ?'
508+ // sql: 'SELECT id, email FROM users WHERE " active" = ?; '
497509// params: [true]
498510```
499511
@@ -504,10 +516,14 @@ const { sql, params } = users.from(db)
504516const plan = await users .from (db ).where ({ id: 1 }).explain ().execute ();
505517const { sql } = users .from (db ).where ({ id: 1 }).explain ();
506518// SQLite: "EXPLAIN QUERY PLAN ..."
507- // PostgreSQL: "EXPLAIN ANALYZE ..."
519+ // PostgreSQL: "EXPLAIN ANALYZE ..." (ANALYZE runs the query for real)
508520// Others: "EXPLAIN ..."
509521```
510522
523+ ` explain_analyze() ` returns a plain ` CompiledQuery ` (not executable) with a
524+ literal ` EXPLAIN ANALYZE ` prefix on every dialect, which SQLite does not
525+ accept.
526+
511527---
512528
513529## 8. WHERE Conditions: Complete Reference
@@ -555,12 +571,13 @@ const { sql } = users.from(db).where({ id: 1 }).explain();
555571### Expression helpers (preferred for complex conditions)
556572
557573``` ts
558- import { eq , ne , gt , gte , lt , lte , like , ilike , inList , isNull , isNotNull , and , or , not , raw } from " @coderbuzz/sql" ;
574+ // Not exported from the root: take them from the dialect namespace
575+ const { eq, ne, gt, gte, lt, lte, like, ilike, inList, isNull, isNotNull, and, or, not, raw } = pg ;
559576
560577.where (eq (" id" , 5 ))
561578.where (gte (" score" , 90 ))
562579.where (like (" email" , " %@example.com" ))
563- .where (ilike (" name" , " %alice%" )) // case-insensitive, PostgreSQL
580+ .where (ilike (" name" , " %alice%" )) // emits ILIKE on every dialect; only PostgreSQL accepts it
564581.where (inList (" id" , [1 , 2 , 3 ]))
565582.where (isNull (" deleted_at" ))
566583.where (isNotNull (" email" ))
@@ -569,7 +586,9 @@ import { eq, ne, gt, gte, lt, lte, like, ilike, inList, isNull, isNotNull, and,
569586.where (and (eq (" active" , true ), gte (" score" , 90 ), not (isNull (" email" ))))
570587.where (or (eq (" role" , " admin" ), eq (" role" , " owner" )))
571588
572- // Raw fragment with params
589+ // Raw fragment with params. The SQL is inserted verbatim: use `?`, which the
590+ // pg/mssql engines rewrite to $N/@pN at execute time. On PostgreSQL/MSSQL this
591+ // is only correct when no other parameter precedes it (see section 24).
573592.where (raw (" created_at > NOW() - INTERVAL ? DAY" , [7 ]))
574593
575594// Raw fragment (no params)
@@ -645,9 +664,10 @@ done.
645664``` ts
646665const batcher = db .batchInsert (" events" , {
647666 wait: 50 , // ms of inactivity before flush (REQUIRED)
648- max: 5_000 , // flush when pending reaches this count
649- timeout: 2_000 , // force flush after this many ms from first write
650- maxInflight: 4 , // concurrent flushes allowed before write() waits
667+ max: 5_000 , // flush when pending reaches this count (default 1000)
668+ timeout: 2_000 , // force flush after this many ms from first write (default 5000)
669+ maxInflight: 4 , // concurrent flushes allowed before write() waits (default 4)
670+ heterogeneousRows: " reject" , // default; "union" fills absent keys with NULL
651671 settings: { async_insert: " 1" }, // engine-specific (ClickHouse)
652672 onError : (err , rows ) => { /* REQUIRED: retry or dead-letter these rows */ },
653673});
@@ -778,7 +798,9 @@ await db.transaction(fn, {
778798
779799` setup ` is where ` SET LOCAL ` belongs: it is the mechanism PostgreSQL
780800row-level security depends on, and it is correct only inside a
781- single-connection transaction.
801+ single-connection transaction. For shared-schema multi-tenancy prefer
802+ ` engine.forTenant(tenantId(id)) ` (both PostgreSQL engines); the full guide is
803+ ` docs/multi-tenancy.md ` in the monorepo.
782804
783805### Savepoints
784806
@@ -833,7 +855,7 @@ db.use(async (query, next) => {
833855
834856## 16. Streaming and Prepared Queries
835857
836- ### Streaming (SQLite + PostgreSQL only)
858+ ### Streaming (SQLite on Bun + PostgreSQL only)
837859
838860``` ts
839861for await (const row of users .from (db ).where ({ active: true }).stream ()) {
@@ -842,12 +864,15 @@ for await (const row of users.from(db).where({ active: true }).stream()) {
842864// Throws on unsupported dialects: "Streaming is not supported by this dialect."
843865```
844866
845- ### Prepared queries (SQLite + PostgreSQL only)
867+ ### Prepared queries (SQLite on Bun + PostgreSQL only)
868+
869+ ` sqlite-node ` and ` sqlite-deno ` do not override ` stream() ` /` prepare() ` , so on
870+ Node.js and Deno ` @coderbuzz/sql/sqlite ` throws for both.
846871
847872``` ts
848873const prepared = users .from (db ).where ({ id: 1 }).prepare ();
849874const rows = await prepared .execute ();
850- prepared .close ();
875+ await prepared .close (); // Promise on PostgreSQL: DEALLOCATEs and releases the held connection
851876// Throws on unsupported dialects: "Prepared statements are not supported by this dialect."
852877```
853878
@@ -1030,7 +1055,7 @@ ch.uuid() ch.ipv4() ch.ipv6() ch.lowCardinality(type)
10301055| **Identifier quoting** | ` " id" ` (PG, SQLite) · ` ` ` id ` ` ` (MySQL, CH) · ` [id ]` (MSSQL) |
10311056| **Placeholders** | ` ? ` (SQLite/MySQL/CH) · ` $N ` (PG) · ` @pN ` (MSSQL) |
10321057| **RETURNING** | PostgreSQL + SQLite only. Others throw ` " RETURNING is not supported by this dialect" ` |
1033- | **FULL OUTER JOIN** | PostgreSQL + ANSI only . SQLite/MySQL/ClickHouse throw at compile time |
1058+ | **FULL OUTER JOIN** | PostgreSQL, MSSQL and ANSI . SQLite/MySQL/ClickHouse throw at compile time |
10341059| **ClickHouse params** | Values inlined into SQL (HTTP API has no native binding). Safe via ` escapeClickHouseValue ()` |
10351060| **ClickHouse CREATE INDEX** | Not emitted. Indexes are defined via the ENGINE / ORDER BY clause |
10361061| **ClickHouse UNIQUE** | Not supported. Throws if ` .unique ()` is used in a ClickHouse table |
@@ -1079,7 +1104,7 @@ const compiled = users.from(db)
10791104 .toSQL ();
10801105
10811106console .log (compiled .sql );
1082- // SELECT "id", " name" FROM users WHERE (active = ? AND score >= ?) ORDER BY id ASC LIMIT 10
1107+ // SELECT id, name FROM users WHERE (" active" = ? AND " score" >= ?) ORDER BY id ASC LIMIT 10;
10831108console .log (compiled .params );
10841109// [true, 90]
10851110` ` `
@@ -1135,8 +1160,8 @@ const rows = await db.execute(`SELECT * FROM users WHERE name = '${name}'`);
11351160const rows = await db .sql ` SELECT * FROM users WHERE name = ${name } ` .execute ();
11361161` ` `
11371162
1138- **DO NOT** call ` .stream ()` or ` .prepare ()` on MySQL, MSSQL, or ClickHouse
1139- engines : they throw.
1163+ **DO NOT** call ` .stream ()` or ` .prepare ()` on MySQL (either engine) , MSSQL,
1164+ ClickHouse, or SQLite on Node.js/Deno : they throw.
11401165
11411166**DO NOT** use ` .returning ()` on MySQL, MSSQL, or ClickHouse: throws ` " RETURNING is not supported by this dialect" ` .
11421167
@@ -1147,6 +1172,12 @@ compile time.
11471172
11481173**DO NOT** forget ` await batcher .close ()` : rows may be left unwritten.
11491174
1175+ **DO NOT** put ` raw (sql , params )` after another parameterised condition on
1176+ PostgreSQL or MSSQL. The fragment's ` ? ` is left as-is at compile time while the
1177+ other placeholders are numbered, so ` and (eq (' a' , 1 ), raw (' b > ?' , [2 ]))`
1178+ compiles to ` " a" = $1 AND b > ? ` , and the engine's ` ? ` -to- ` $N ` rewrite then
1179+ turns it into ` b > $1 ` , binding ` 1 ` instead of ` 2 ` .
1180+
11501181**DO NOT** use the pooled engine inside a transaction callback. Use ` tx ` :
11511182
11521183` ` ` ts
@@ -1161,7 +1192,8 @@ await db.transaction(async (tx) => { await tx.execute(insertSql); });
11611192` .from ()` , or a join ` ON ` condition. These cannot be bound parameters, so they
11621193are interpolated. They are validated and will throw ` UnsafeIdentifierError ` on
11631194anything dangerous, but that is a guard, not a licence: map a sort parameter
1164- through a fixed allow-list of column names:
1195+ through a fixed allow-list of column names. ` assertSafeFragment ()` (root
1196+ export) runs the same check on a fragment you build yourself:
11651197
11661198` ` ` ts
11671199// WRONG
@@ -1194,7 +1226,7 @@ so there is no conversion at the HTTP boundary, and conversions are where
11941226precision goes:
11951227
11961228``` ts
1197- import { decimal, object } from " @coderbuzz/veta" ;
1229+ import { decimal , object , string } from " @coderbuzz/veta" ;
11981230
11991231const postJournal = object ({
12001232 ref: string (),
@@ -1212,9 +1244,10 @@ returns a float64. Only the typed path rewrites it. In raw SQL, select
12121244column on SQLite; they run on text or floats. Fetch and use
12131245` @coderbuzz/sql/decimal ` (` sumDecimals ` , ` compareDecimals ` ).
12141246
1215- **Expect ` BIGINT ` and ` COUNT (*)` as strings in raw MySQL results.** The engine
1216- enables ` bigNumberStrings ` , matching PostgreSQL. ` count ()` on the typed path
1217- still returns a number.
1247+ ** Expect ` BIGINT ` and ` COUNT(*) ` as strings in raw ` mysql2 ` results.** The
1248+ ` @coderbuzz/sql/mysql ` engine enables ` bigNumberStrings ` , matching PostgreSQL.
1249+ The ` mysql-bun ` engine returns them as numbers when they fit a float64 (see
1250+ section 27). ` count() ` on the typed path returns a number on both.
12181251
12191252** DO NOT** use ` DELETE ` or ` UPDATE ` without ` .where() ` unless you intend to
12201253affect all rows. Add a middleware guard in production code.
@@ -1453,7 +1486,7 @@ db.tenantSetting; // string
14531486db .client ; // the raw Bun.SQL object (LISTEN/NOTIFY, file(), beginDistributed)
14541487db .forTenant (tenantId (id )); // TenantScopedSql
14551488db .unsafeCrossTenant (reason , async admin => ... ); // needs config.crossTenant
1456- db.withAdvisoryLock(key, fn, wait? ); // Promise<R | undefined>
1489+ db .withAdvisoryLock (key , fn , wait = true ); // Promise<R | undefined>; wait=false → undefined if held
14571490db .stream (compiledQuery ); // AsyncIterable<row>
14581491db .prepare (compiledQuery ); // { execute(params?), close() }
14591492await db .close (); // closes both clients
@@ -1656,9 +1689,13 @@ const rows = await db.sql<{ id: number; name: string }[]>`
16561689### Batch write + close
16571690
16581691``` ts
1659- const batcher = db.batchInsert("logs", { wait: 100, max: 1000 });
1692+ const batcher = db .batchInsert (" logs" , {
1693+ wait: 100 ,
1694+ max: 1000 ,
1695+ onError : (err , rows ) => deadLetter .push ({ err , rows }), // required
1696+ });
16601697for (const log of logBuffer ) {
1661- batcher.write(log);
1698+ await batcher .write (log );
16621699}
16631700await batcher .close ();
16641701```
@@ -1668,10 +1705,10 @@ await batcher.close();
16681705``` ts
16691706try {
16701707 await db .transaction (async (tx ) => {
1671- await accounts.update(tx). set({ balance: db.sql ` balance - $ { amount } ` })
1672- .where({ id: fromId }).execute();
1673- await accounts.update(tx).set({ balance: db.sql ` balance + $ {amount }` })
1674- .where({ id: toId }) .execute();
1708+ // . set() binds every value as a parameter, so an expression such as
1709+ // `balance - x` has to go through the tagged template instead.
1710+ await tx . sql ` UPDATE accounts SET balance = balance - ${amount } WHERE id = ${ fromId } ` . execute ();
1711+ await tx . sql ` UPDATE accounts SET balance = balance + ${ amount } WHERE id = ${ toId } ` .execute ();
16751712 });
16761713} catch (e ) {
16771714 // transaction was rolled back automatically
@@ -1716,10 +1753,11 @@ const rows = await db
17161753
17171754```
17181755Package: @coderbuzz/sql
1719- Version: 0.1.3
1756+ Version: 0.8.1
17201757License: MIT
17211758Type: ESM only (type: "module")
1722- Peer deps (all optional): pg, mysql2, mssql, better-sqlite3, @db/sqlite
1759+ Peer deps (all optional): pg, mysql2, mssql, better-sqlite3
1760+ (@db/sqlite, used on Deno, is loaded at runtime but not declared as a peer dep)
17231761Runtime dep: @coderbuzz/veta (internal, schema coercion)
17241762```
17251763
@@ -1742,3 +1780,26 @@ Runtime dep: @coderbuzz/veta (internal, schema coercion)
17421780| ` @coderbuzz/sql/mssql-types ` | MSSQL column factories only |
17431781| ` @coderbuzz/sql/clickhouse-types ` | ClickHouse column factories only |
17441782| ` @coderbuzz/sql/ansi ` | ANSI column factories only |
1783+
1784+ ---
1785+
1786+ ## 30. Benchmarks
1787+
1788+ Machine-readable source of truth: ` https://raw.githubusercontent.com/coderbuzz/benchmarks/main/results/latest.json ` (each entry has ` winner ` , ` factorVsNext ` , ` higherIsBetter ` ). Quote numbers from there, not from memory.
1789+
1790+ Full results at ** [ github.com/coderbuzz/benchmarks] ( https://github.com/coderbuzz/benchmarks ) ** .
1791+
1792+ SQL query compilation throughput on Apple M-series, Bun runtime. Higher is better.
1793+
1794+ | Scenario | @coderbuzz/sql | Kysely | Factor vs Kysely | Drizzle ORM | Factor vs Drizzle |
1795+ | ---| ---| ---| ---| ---| ---|
1796+ | SELECT simple | ** 1,700,391 ops/s** | 561,869 | ** 3.0x** | 34,239 | ** 49.7x** |
1797+ | SELECT JOIN (2 tables) | ** 2,208,322 ops/s** | 310,690 | ** 7.1x** | 16,801 | ** 131.4x** |
1798+ | INSERT single row | ** 2,954,261 ops/s** | 393,159 | ** 7.5x** | 55,085 | ** 53.6x** |
1799+ | INSERT batch 100 rows | ** 134,187 ops/s** | 17,634 | ** 7.6x** | 914 | ** 146.8x** |
1800+ | CTE (WITH clause) | ** 831,703 ops/s** | 224,893 | ** 3.7x** | 12,222 | ** 68.0x** |
1801+ | 10 nested WHERE conditions | ** 656,309 ops/s** | 117,064 | ** 5.6x** | 12,692 | ** 51.7x** |
1802+
1803+ ` @coderbuzz/sql ` is 3-8x faster than Kysely and 50-147x faster than Drizzle ORM across every query type. The gap widens with query complexity (batch, JOIN, conditions) due to ` @coderbuzz/sql ` 's zero-overhead string compilation strategy vs Kysely's AST-based approach and Drizzle's ORM abstraction layer.
1804+
1805+ These measure query ** compilation** only (building the SQL string), not database round trips.
0 commit comments