Skip to content

chadscript/postgres: native postgres client via libpq#512

Merged
cs01 merged 6 commits intomainfrom
postgres-bridge
Apr 15, 2026
Merged

chadscript/postgres: native postgres client via libpq#512
cs01 merged 6 commits intomainfrom
postgres-bridge

Conversation

@cs01
Copy link
Copy Markdown
Owner

@cs01 cs01 commented Apr 14, 2026

What you get

import { Pool } from "chadscript/postgres";

const pool = new Pool("postgresql://postgres:secret@localhost:5432/mydb");

pool.query("CREATE TABLE users (id INT, name TEXT, age INT)");
pool.query("INSERT INTO users VALUES ($1, $2, $3)", ["1", "alice", "30"]);

const res = pool.query("SELECT id, name, age FROM users WHERE age > $1", ["18"]);
for (let i = 0; i < res.numRows; i++) {
  console.log(res.getInt(i, "id") + " " + res.getValue(i, "name"));
}

pool.end();

A first-class PostgreSQL client for ChadScript, built on libpq. Pool is the recommended entry point; Client is available for low-level lifecycle control. Parameterized queries with $1/$2 placeholders are safe against SQL injection — libpq handles escaping.

Why

Postgres is the most common server database, and until now ChadScript had no story for talking to it. You had to shell out to psql or write raw sockets. This closes that gap and lines up sqlite (ships today) + postgres (this PR) as the two supported databases, putting ChadScript on equal footing with Node for backend work that uses a real RDBMS.

This is also the first stdlib module built with the declare function cs_* + prefix-detection pattern, which makes adding new C-backed modules basically trivial going forward (MySQL and Redis next).

What's in the box

  • Pool — recommended primary API, lazy-connects on first query
  • Client — low-level explicit connect/end lifecycle
  • QueryResult with rowCount, numRows, numCols, fields, and accessors getValue, getInt, getFloat, getBool, getRow
  • Parameterized queries via $1, $2, ... — builds libpq PQexecParams via a stateful param-builder pattern to avoid marshaling a string[] across the FFI boundary
  • Full docs at docs/stdlib/postgres.md, listed in docs/stdlib/index.md
  • 4 test fixtures covering connect, rowCount, SELECT + row access, Pool, and parameterized queries with type coercion (marked @test-skip — they need a running postgres; CI service container is in a follow-up)

Current limitations (documented)

  • All values are strings under the hood. getInt/getFloat/getBool parse on access. No OID-based auto-typing — users know their schema, they call the right getter.
  • Pool is a thin wrapper over a single Client. No real connection reuse yet. Calls are sequential. Real pooling (multiple conns, queuing, limits) needs async, which is a follow-up.
  • Synchronous under the hood. libpq calls block. Real async (libuv integration) is a follow-up.
  • No null detection yet. My bridge returns empty string for NULL, which collides with legitimate empty text. Parallel null flags are a follow-up.
  • No LISTEN/NOTIFY, no COPY, no streaming. The basics only.
  • libpq install required. macOS: brew install libpq. Debian/Ubuntu: apt install libpq-dev. The build gracefully skips pg-bridge.o if headers are missing.

How it works

New convention: any C bridge function declared with the cs_pg_ prefix auto-flips a usesPostgres flag in the codegen, and the linker picks up -lpq + pg-bridge.o without any per-function wiring. Same pattern as cs_llvm_* / cs_lld_*. This is the pattern the future sqlite migration and MySQL/Redis stdlib modules should follow.

Also fixed an ABI gotcha that would have silently bitten future bridges: C bridge functions called from ChadScript as number must use double in C, not int. ARM64 returns ints in w0 and doubles in d0 — completely separate registers. Memory note saved at memory/c-bridge-abi-double-rule.md.

Test plan

  • npm run verify:quick green (tests + stage 1 self-hosting)
  • Manual e2e against a local podman run postgres:16 — all 4 fixtures pass:
    • postgres-connect.ts — connect + end
    • postgres-query-rowcount.ts — CREATE, INSERT, UPDATE, DELETE with expected rowCounts
    • postgres-query-select.ts — SELECT 3 rows, column lookup by name, empty result set
    • postgres-pool.ts — Pool lazy-connect + CRUD
    • postgres-params-types.ts — parameterized queries, getInt/getFloat/getBool, SQL-injection-safety with a quoted value
  • CI postgres service container (follow-up PR)
  • Flagship URL-shortener example (follow-up PR)

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 14, 2026

Benchmark Results (Linux x86-64)

Benchmark C ChadScript Go Node Place
Binary Trees 1.398s 1.228s 2.733s 1.156s 🥈
Cold Start 0.9ms 0.8ms 1.2ms 26.4ms 🥇
Fibonacci 0.814s 0.763s 1.560s 3.159s 🥇
File I/O 0.117s 0.091s 0.087s 0.207s 🥈
JSON Parse/Stringify 0.004s 0.005s 0.017s 0.015s 🥈
Matrix Multiply 0.456s 0.714s 0.566s 0.371s #4
Monte Carlo Pi 0.389s 0.410s 0.405s 2.248s 🥉
N-Body Simulation 1.671s 2.126s 2.201s 2.388s 🥈
Quicksort 0.215s 0.246s 0.213s 0.262s 🥉
SQLite 0.352s 0.376s 0.418s 🥈
Sieve of Eratosthenes 0.014s 0.028s 0.018s 0.039s 🥉
String Manipulation 0.008s 0.017s 0.016s 0.036s 🥉

CLI Tool Benchmarks

Benchmark ChadScript grep node xxd Place
Hex Dump 0.429s 0.980s 0.129s 🥈
Recursive Grep 0.018s 0.009s 0.097s 🥈

cs01 added 6 commits April 14, 2026 21:31
adds c_bridges/pg-bridge.c with thin wrappers around libpq for connect,
exec, parameterized exec, and result introspection. build-vendor.sh
detects libpq headers (keg-only brew install on darwin, system paths on
linux) and gracefully skips when absent. no codegen or ts wiring yet —
this commit is just the foundation for chadscript/postgres.
…tep 2/10)

adds chadscript/postgres module. lib/postgres.ts uses declare function
bindings to cs_pg_* externs — compiler auto-detects the cs_pg_ prefix
in llvm-generator.ts, flips a new usesPostgres flag, and the link step
picks up -lpq plus homebrew keg-only libpq lib path. pg-bridge.o is
included conditionally. Client class exposes connect()/end() — query
comes in step 3.

verified end-to-end: compiled a fixture that connects to a local
podman postgres and cleanly disconnects. the cs_pg_connect extern is
called directly from the TS Client.connect method, no codegen
special-casing required. pattern matches cs_llvm_*/cs_lld_*.

workaround: added trailing return in parser-native transformExpression
for the known checkMissingReturns false-positive
(memory/native-missing-returns-false-positive.md) — the usesPostgres
field addition shifted AST layouts and tripped the bug.
adds Client.query(sql) that returns QueryResult with rowCount for
INSERT/UPDATE/DELETE. test fixture covers single insert, multi-row
insert, update, and delete.

abi fix: c functions called from chadscript as returning 'number' must
return 'double', not 'int' — chadscript maps 'number' to llvm double,
which lives in d0 on arm64 while c int returns come back in w0,
completely different registers. previous step 2 passed by luck because
CONNECTION_OK happens to be 0 and d0 was zero-initialized.

fixed the whole bridge preemptively: all int-returning cs_pg_* now
return double, all int parameters (row, col, nparams) are now double
with internal cast. matches the pattern cs_lld_available uses.
adds nrows/ncols/fname/getvalue/getisnull extern bindings and SELECT
row access. QueryResult now holds flat { fields, values } plus numRows
/numCols — getValue(row, col) walks the fields[] to resolve a column
name to its offset and returns the string. getRow(idx) constructs a
lightweight Row view for iteration.

flat-storage design avoids chadscript ObjectArray type-loss: storing
Row[] and accessing rows[i].get() inside lib/postgres.ts triggered
compile errors because element access on an object array discards the
concrete type. keeping values as a string[] sidesteps the problem.

test covers 3-row SELECT with column-name lookup, field ordering,
empty result set, and rowCount.

c bridge now gc_strdups all string returns (getvalue, fname, error
messages, cmdtuples) so they survive past cs_pg_clear — previously
those pointers dangled into the freed PGresult.
…+6/10)

Pool: thin wrapper over Client that lazy-connects on first query. v1
is sequential — real pooling (multiple conns, queuing) needs async and
is a follow-up. matches node-pg's Pool entry point so copy-paste from
tutorials works. Pool is now the documented primary API; Client is
the low-level escape hatch.

Parameterized queries: pool.query(sql, params) uses libpq's PQexecParams
via a stateful param-builder pattern (cs_pg_params_new / _add /
_exec_params_with). avoids marshaling a chadscript string[] across the
ffi boundary — the %StringArray struct layout doesn't match libpq's
const char**. TS passes values one at a time; c side grows its own
dynamic array. placeholders are $1, $2, $3 (postgres native syntax).
libpq handles escaping so user input with quotes or semicolons is safe.

Typed accessors on QueryResult: getInt, getFloat, getBool delegate to
parseInt/parseFloat/string compare — saves users from writing boiler-
plate parsing on every access. no OID-based auto-typing — users know
their schema, they call the right getter. avoids type-coercion surprises.

Docs: new docs/stdlib/postgres.md with Pool-first API, documented
limitations (sync, no null detection, string-only params, no real
pooling). chadscript/postgres added to stdlib module index.

test coverage: postgres-pool (Pool lazy-connect + CRUD),
postgres-params-types (getInt/Float/Bool + parameterized queries +
sql-injection safety with quoted param values).
step 2 added a usesPostgres field to LLVMGenerator plus a cs_pg_ prefix
check in generateExternDeclaration plus a trailing-return workaround in
parser-native/transformer.ts. any of those shifted enough state that the
native stage 1 compiler began miscompiling generateGlobalVariableDeclarations
— storing a pointer into a double slot for isClassInstance. --quick
skipped stage 2 so it passed local verify but failed CI.

fix: do the -lpq/pg-bridge.o detection entirely in compiler.ts and
native-compiler-lib.ts by walking generator.declaredExternFunctions for
anything starting with cs_pg_. no changes to llvm-generator.ts,
generator-context.ts, or parser-native/transformer.ts. feature is
functionally identical end-to-end (verified against local postgres).

npm run verify (full, including stage 2) is green on this commit.
@cs01 cs01 force-pushed the postgres-bridge branch from 6e01a06 to f4f8150 Compare April 15, 2026 04:31
@cs01 cs01 merged commit a264127 into main Apr 15, 2026
13 checks passed
cs01 added a commit that referenced this pull request Apr 15, 2026
…ative compiler

the postgres fixtures shipped in #512 were @test-skip — ci never exercised
them, so any regression in lib/postgres.ts or pg-bridge.c would land
silently. this gates them behind a new `@test-requires-env: PG_TESTS_ENABLED`
annotation and wires up postgres in both ci jobs:

- linux: services: postgres:16 container, libpq-dev installed, PG_TESTS_ENABLED=1
- macos: brew install libpq postgresql@16, brew services start, provision postgres
  user/password/chadtest db, PG_TESTS_ENABLED=1

also fixes a separate gap in #512: src/chad-native.ts never called
registerStdlib for postgres.ts, so .build/chad rejected
`import { Pool } from "chadscript/postgres"` with 'stdlib module not found'.
all my prior verification went through node dist/chad-node.js which reads
lib/*.ts from disk — the embedded-stdlib path was untested. fixed by adding
the registerStdlib call alongside the other lib/ modules.

new test-discovery annotation: `@test-requires-env: VAR` skips a fixture
unless the env var is set and non-empty. enables conditional integration
tests without a separate test runner. extra docs added to the annotation
header in test-discovery.ts.

pg-bridge.o now appears in the verify-vendor loop, the package release
artifact cp commands (linux + macos), and scripts/build-target-sdk.sh,
matching the pattern used by every other bridge.

verified locally: full `PG_TESTS_ENABLED=1 npm run verify` green against
podman postgres:16 — all 5 fixtures pass, stage 0/1/2 self-hosting clean.
cs01 added a commit that referenced this pull request Apr 15, 2026
* ci: run postgres fixtures on linux + macos, register postgres.ts in native compiler

the postgres fixtures shipped in #512 were @test-skip — ci never exercised
them, so any regression in lib/postgres.ts or pg-bridge.c would land
silently. this gates them behind a new `@test-requires-env: PG_TESTS_ENABLED`
annotation and wires up postgres in both ci jobs:

- linux: services: postgres:16 container, libpq-dev installed, PG_TESTS_ENABLED=1
- macos: brew install libpq postgresql@16, brew services start, provision postgres
  user/password/chadtest db, PG_TESTS_ENABLED=1

also fixes a separate gap in #512: src/chad-native.ts never called
registerStdlib for postgres.ts, so .build/chad rejected
`import { Pool } from "chadscript/postgres"` with 'stdlib module not found'.
all my prior verification went through node dist/chad-node.js which reads
lib/*.ts from disk — the embedded-stdlib path was untested. fixed by adding
the registerStdlib call alongside the other lib/ modules.

new test-discovery annotation: `@test-requires-env: VAR` skips a fixture
unless the env var is set and non-empty. enables conditional integration
tests without a separate test runner. extra docs added to the annotation
header in test-discovery.ts.

pg-bridge.o now appears in the verify-vendor loop, the package release
artifact cp commands (linux + macos), and scripts/build-target-sdk.sh,
matching the pattern used by every other bridge.

verified locally: full `PG_TESTS_ENABLED=1 npm run verify` green against
podman postgres:16 — all 5 fixtures pass, stage 0/1/2 self-hosting clean.

* ci: pg_config-first libpq detection in build-vendor.sh

ubuntu/debian's libpq-dev installs libpq-fe.h to /usr/include/postgresql/,
not /usr/include/, so the previous fallback (cc -xc -fsyntax-only against
default include path) silently failed on linux. pg-bridge.o was never
built, the verify-vendor step caught the missing object, and ci #514's
build-linux-glibc job failed in 1m22s.

new detection order:
1. pg_config --includedir — works on any platform with libpq dev headers
2. brew --prefix libpq — keg-only on macos, no pg_config in PATH
3. /usr/include/postgresql/libpq-fe.h — debian/ubuntu fallback
4. default cc include path — last resort

also bumps the cache key (file content hash includes build-vendor.sh),
which forces ci to re-run build-vendor instead of restoring a stale
c_bridges/ from before libpq was installed.

---------

Co-authored-by: cs01 <cs01@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant