1- <!-- docs: sync from coderbuzz/codex@b1e2bde -->
1+ <!-- docs: sync from coderbuzz/codex@4eb7d4a -->
22
33# @coderbuzz/sql — AI Expert Knowledge Reference
44
@@ -206,7 +206,7 @@ const users = pg.table("users", {
206206 email: pg .text ().notNull ().unique ().index (),
207207 name: pg .varchar (120 ).notNull (),
208208 role: pg .varchar (20 ).default (" user" ),
209- score: pg .decimal (5 , 2 ).default (0 ),
209+ score: pg .decimal (5 , 2 ).default (0 ), // → string (exact); use float() for approximate
210210 bio: pg .text ().nullable (),
211211 metadata: pg .jsonb <Record <string , unknown >>().nullable (),
212212 created_at: pg .timestamptz ().defaultNow (),
@@ -559,12 +559,14 @@ const batcher = db.batchInsert("events", {
559559 wait: 50 , // ms of inactivity before flush (REQUIRED)
560560 max: 5_000 , // flush when pending reaches this count
561561 timeout: 2_000 , // force flush after this many ms from first write
562+ maxInflight: 4 , // concurrent flushes allowed before write() waits
562563 settings: { async_insert: " 1" }, // engine-specific (ClickHouse)
564+ onError : (err , rows ) => { /* REQUIRED — retry or dead-letter these rows */ },
563565});
564566
565- // Write rows
566- batcher .write ({ id: 1 , val: " a" });
567- batcher .write ([{ id: 2 , val: " b" }, { id: 3 , val: " c" }]);
567+ // Write rows — write() returns a promise; awaiting it applies backpressure
568+ await batcher .write ({ id: 1 , val: " a" });
569+ await batcher .write ([{ id: 2 , val: " b" }, { id: 3 , val: " c" }]);
568570
569571// Inspect state
570572batcher .pendingCount ; // rows waiting to flush
@@ -578,8 +580,15 @@ await batcher.close(); // flush + drain + seal (throws if write() called after)
578580
579581** Critical rules:**
580582
583+ - ` onError ` is REQUIRED. The constructor throws without it. Rows leave the
584+ pending queue before the insert runs, so a failed auto-flush has no other way
585+ to be observed.
581586- Always ` await batcher.close() ` at the end — never fire-and-forget.
582- - After ` close() ` , calling ` write() ` throws.
587+ - After ` close() ` , calling ` write() ` rejects.
588+ - Every row in a batch must have the SAME keys — a batched INSERT has one
589+ column list. A differing row rejects. Pass ` heterogeneousRows: "union" ` to
590+ insert the union of all columns with NULL for absent ones.
591+ - ` await ` each ` write() ` in a bulk import; that is what keeps memory flat.
583592- Auto-flushes are fire-and-forget internally but tracked — ` drain() ` waits for
584593 them.
585594
@@ -655,16 +664,64 @@ Placeholder styles per dialect:
655664
656665## 14. Transactions
657666
667+ ` transaction() ` holds ONE connection for the whole callback. Use ` tx ` for every
668+ statement inside — ` db ` is a pool and would run the statement on a different
669+ connection, outside the transaction.
670+
658671``` ts
659672await db .transaction (async (tx ) => {
660- // Use `tx` exactly like `db` inside the transaction
661673 await users .insert (tx ).values ([... ]).execute ();
662674 await posts .insert (tx ).values ([... ]).execute ();
663- // Throws? → auto ROLLBACK
675+ // Throws? → ROLLBACK
676+ });
677+ // Success → COMMIT
678+ ```
679+
680+ ### Options
681+
682+ ``` ts
683+ await db .transaction (fn , {
684+ isolation: " SERIALIZABLE" , // also READ COMMITTED / REPEATABLE READ / READ UNCOMMITTED
685+ readOnly: true , // PostgreSQL / MySQL only
686+ setup: [ // runs inside the transaction, on its connection
687+ { sql: ` SELECT set_config('app.tenant_id', $1, true) ` , params: [tenantId ] },
688+ ],
664689});
665- // Success → auto COMMIT
666690```
667691
692+ ` setup ` is where ` SET LOCAL ` belongs — it is the mechanism PostgreSQL
693+ row-level security depends on, and it is correct only inside a
694+ single-connection transaction.
695+
696+ ### Savepoints
697+
698+ ``` ts
699+ await db .transaction (async (tx ) => {
700+ await postHeader (tx );
701+ try {
702+ await tx .savepoint (async (sp ) => reserveStock (sp )); // rolls back alone
703+ } catch { /* header survives */ }
704+ });
705+ ```
706+
707+ ` tx.transaction(...) ` inside a transaction becomes a savepoint, not a second
708+ ` BEGIN ` . ` db.savepoint(...) ` outside a transaction throws.
709+
710+ ### Row locking
711+
712+ ``` ts
713+ tx .select (" last_no" ).from (" nomor_faktur" ).where ({ seri: " A" }).forUpdate ()
714+ // .forShare(), .forUpdate({ noWait: true }), .forUpdate({ skipLocked: true })
715+ ```
716+
717+ PostgreSQL / MySQL / Oracle only. SQLite, MSSQL and ClickHouse throw.
718+
719+ ### Errors
720+
721+ If the callback fails AND the ` ROLLBACK ` also fails, a
722+ ` TransactionRollbackError ` is thrown carrying both ` cause ` and ` rollbackError ` .
723+ The transaction's outcome is undetermined — reconcile, do not just retry.
724+
668725---
669726
670727## 15. Middleware
@@ -727,6 +784,12 @@ max<number>("score", "topScore"); // MAX(score) AS topScore
727784
728785## 18. Column Types by Dialect
729786
787+ > ** TypeScript type of exact numerics.** ` decimal(p,s) ` , ` numeric(p,s) ` ,
788+ > ` bigint() ` , ` bigserial() ` and MSSQL ` money() ` infer as ** ` string ` ** , not
789+ > ` number ` — float64 cannot represent them exactly, and ` pg ` /` mysql2 ` return
790+ > them as strings anyway. ` integer ` , ` smallint ` , ` int ` , ` serial ` , ` float ` ,
791+ > ` real ` and ` doublePrecision ` remain ` number ` .
792+
730793### SQLite
731794
732795``` ts
@@ -931,9 +994,46 @@ ClickHouse — throws `"RETURNING is not supported by this dialect"`.
931994**DO NOT** use ` .full_join ()` with SQLite, MySQL, or ClickHouse — throws at
932995compile time.
933996
934- **DO NOT** call ` batcher .write ()` after ` batcher .close ()` — throws.
997+ **DO NOT** call ` batcher .write ()` after ` batcher .close ()` — rejects.
998+
999+ **DO NOT** forget ` await batcher .close ()` — rows may be left unwritten.
1000+
1001+ **DO NOT** use the pooled engine inside a transaction callback. Use ` tx ` :
1002+
1003+ ` ` ` ts
1004+ // WRONG — this INSERT runs on a different connection, outside the transaction
1005+ await db .transaction (async (tx ) => { await db .execute (insertSql ); });
1006+
1007+ // CORRECT
1008+ await db .transaction (async (tx ) => { await tx .execute (insertSql ); });
1009+ ` ` `
1010+
1011+ **DO NOT** pass user input to ` .order_by ()` , ` .group_by ()` , ` .select ()` ,
1012+ ` .from ()` , or a join ` ON ` condition. These cannot be bound parameters, so they
1013+ are interpolated. They are validated and will throw ` UnsafeIdentifierError ` on
1014+ anything dangerous, but that is a guard, not a licence — map a sort parameter
1015+ through a fixed allow-list of column names:
1016+
1017+ ` ` ` ts
1018+ // WRONG
1019+ query .order_by (req .query .sort );
1020+
1021+ // CORRECT
1022+ const SORTS = { date: " created_at DESC" , amount: " total DESC" } as const ;
1023+ query .order_by (SORTS [req .query .sort as keyof typeof SORTS ] ?? SORTS .date );
1024+ ` ` `
9351025
936- **DO NOT** forget ` await batcher .close ()` — rows may be silently lost.
1026+ **DO NOT** treat ` decimal ` / ` numeric ` / ` bigint ` / ` bigserial ` values as numbers.
1027+ They are typed ` string ` because float64 cannot hold them exactly. ` Number (x )`
1028+ on a money column loses cents:
1029+
1030+ ` ` ` ts
1031+ // WRONG — reintroduces the precision loss the string type exists to prevent
1032+ const total = rows .reduce ((a , r ) => a + Number (r .debit ), 0 );
1033+
1034+ // CORRECT — sum in SQL, or use a decimal library
1035+ const [{ total }] = await db .sql ` SELECT SUM(debit)::text AS total FROM jurnal ` .execute ();
1036+ ` ` `
9371037
9381038**DO NOT** use ` DELETE ` or ` UPDATE ` without ` .where ()` unless you intend to
9391039affect all rows. Add a middleware guard in production code.
0 commit comments