CarbonNode is a typed MySQL ORM runtime plus code generator for REST bindings.
It is built for:
- full-stack teams that want one query shape across frontend and backend
- explicit column references and safer query composition
- JSON-serializable SQL expression payloads with predictable parsing
- generated TypeScript bindings from live MySQL schema
It can run in two modes:
- SQL executor (
mysqlPoolset): executes SQL directly in Node - HTTP executor (no pool, axios set): sends requests to a CarbonNode REST endpoint
- Install
- Quickstart
- Execution Model
- C6 + CarbonReact State Management
- Canonical SQL Expression Grammar (6.1.0+)
- Clause-by-Clause Usage
- Helper Builders
- Singular vs Complex Requests
- HTTP Query-String Pitfalls
- Generator Output
- SQL Allowlist
- Lifecycle Hooks and Websocket Broadcast
- Testing
- Migration Notes (6.0 -> 6.1)
- AI Interpretation Contract
- Git Hooks
- Support
npm install @carbonorm/carbonnodePeer dependencies:
mysql2for SQL executor modeaxiosfor HTTP executor modeexpressonly if hosting the REST route in your app
Generate C6.ts + C6.test.ts + dump artifacts into an output directory:
npx generateRestBindings \
--user root \
--pass password \
--host 127.0.0.1 \
--port 3306 \
--dbname sakila \
--prefix "" \
--output ./shared/restImport from generated bindings:
import { C6, GLOBAL_REST_PARAMETERS, Actor } from "./shared/rest/C6";import mysql from "mysql2/promise";
import { GLOBAL_REST_PARAMETERS } from "./shared/rest/C6";
GLOBAL_REST_PARAMETERS.mysqlPool = mysql.createPool({
host: "127.0.0.1",
user: "root",
password: "password",
database: "sakila",
});import { axiosInstance } from "@carbonorm/carbonnode";
import { GLOBAL_REST_PARAMETERS } from "./shared/rest/C6";
GLOBAL_REST_PARAMETERS.axios = axiosInstance;
GLOBAL_REST_PARAMETERS.restURL = "/api/rest/";If you are using CarbonReact, wire reactBootstrap as described in C6 + CarbonReact State Management.
import express from "express";
import mysql from "mysql2/promise";
import { restExpressRequest } from "@carbonorm/carbonnode";
import { C6 } from "./shared/rest/C6";
const app = express();
app.set("query parser", "extended");
app.use(express.json());
const mysqlPool = mysql.createPool({
host: "127.0.0.1",
user: "root",
password: "password",
database: "sakila",
});
restExpressRequest({
router: app,
routePath: "/api/rest/:table{/:primary}",
C6,
mysqlPool,
});import { C6, Actor } from "./shared/rest/C6";
const result = await Actor.Get({
[C6.SELECT]: [
Actor.ACTOR_ID,
Actor.FIRST_NAME,
Actor.LAST_NAME,
],
[C6.WHERE]: {
[Actor.LAST_NAME]: [C6.LIKE, [C6.LIT, "%PITT%"]],
},
[C6.PAGINATION]: {
[C6.LIMIT]: 10,
[C6.PAGE]: 1,
[C6.ORDER]: [[Actor.ACTOR_ID, C6.DESC]],
},
});flowchart LR
Client["App Code\nActor.Get(...)"] --> Request["restRequest facade"]
Request -->|"mysqlPool configured"| SQL["SqlExecutor"] --> DB[("MySQL")]
Request -->|"no mysqlPool"| HTTP["HttpExecutor"] --> REST["/api/rest/:table"]
REST --> Express["ExpressHandler"] --> SQL
restRequest chooses SQL executor when mysqlPool is present; otherwise it uses HTTP executor.
If your app uses CarbonReact, CarbonNode can keep table state in sync automatically after requests.
Set:
import { GLOBAL_REST_PARAMETERS } from "./shared/rest/C6";
GLOBAL_REST_PARAMETERS.reactBootstrap = yourCarbonReactInstance;State sync behavior:
- HTTP executor path:
GET: updates table state with response rowsPOST: inserts created row(s) into statePUT: updates matching row(s) by primary keyDELETE: removes matching row(s) by primary key
- SQL executor path:
GET: updates table state whenreactBootstrapis set- write sync is typically handled by your websocket/event layer
How C6 identifies rows:
stateKeyis table name (restModel.TABLE_NAME)uniqueObjectIdis table primary keys (restModel.PRIMARY_SHORT)
Per-request escape hatch:
await SomeTable.Get({
/* query */
skipReactBootstrap: true,
});This lets you opt out of automatic state writes when a call is read-only for the UI or you are running a background sync pass.
6.1.0 unifies expression parsing across SELECT, WHERE, HAVING, ORDER, and expression-capable UPDATE/INSERT values.
| Purpose | Canonical form |
|---|---|
| Known function | [C6.FUNCTION_NAME, ...args] |
| Custom function | [C6.CALL, "FUNCTION_NAME", ...args] |
| Alias | [C6.AS, expression, "alias"] |
| DISTINCT | [C6.DISTINCT, expression] |
| Literal binding | [C6.LIT, value] |
| ORDER term | [expression, "ASC" | "DESC"] |
Normative rules:
- Bare strings are references only (
table.columnor valid aliases in context). - Non-reference strings must be wrapped with
[C6.LIT, value]. ASandDISTINCTare wrappers, not positional tokens.PAGINATION.ORDERmust be an array of order terms.- Use
C6.CALLfor unknown/custom function names.
Removed legacy syntax (throws):
[fn, ..., C6.AS, alias][column, C6.AS, alias]- object-rooted function expressions like
{ [C6.COUNT]: [...] } - implicit string literals in function arguments
ORDERobject-map syntax (now array terms only)
import { C6, Actor } from "./shared/rest/C6";
const response = await Actor.Get({
[C6.SELECT]: [
[C6.AS, [C6.DISTINCT, Actor.FIRST_NAME], "distinct_name"],
[C6.AS, [C6.COUNT, Actor.ACTOR_ID], "cnt"],
[C6.CALL, "COALESCE", [C6.LIT, "Unknown"], Actor.LAST_NAME],
],
});import { C6, Actor } from "./shared/rest/C6";
const response = await Actor.Get({
[C6.WHERE]: {
[C6.AND]: [
{ [Actor.LAST_NAME]: [C6.LIKE, [C6.LIT, "S%"]] },
{ [Actor.ACTOR_ID]: [C6.BETWEEN, [5, 50]] },
{ [Actor.FIRST_NAME]: [C6.IN, [[C6.LIT, "NICK"], [C6.LIT, "ED"]]] },
],
},
});import { C6, Actor, Film_Actor } from "./shared/rest/C6";
const response = await Actor.Get({
[C6.SELECT]: [Actor.ACTOR_ID, Actor.FIRST_NAME],
[C6.JOIN]: {
[C6.INNER]: {
[Film_Actor.TABLE_NAME]: {
[Film_Actor.ACTOR_ID]: [C6.EQUAL, Actor.ACTOR_ID],
},
},
},
});import { C6, Actor } from "./shared/rest/C6";
const response = await Actor.Get({
[C6.PAGINATION]: {
[C6.ORDER]: [
[Actor.LAST_NAME, C6.ASC],
[Actor.FIRST_NAME, C6.DESC],
],
[C6.LIMIT]: 25,
[C6.PAGE]: 1,
},
});PAGE is 1-based:
PAGE = 1-> first pagePAGE = 2-> second pagePAGE = 0is coerced to1
import { C6, Actor } from "./shared/rest/C6";
await Actor.Put({
[Actor.ACTOR_ID]: 42,
[C6.UPDATE]: {
[Actor.FIRST_NAME]: [C6.CONCAT, [C6.LIT, "Mr. "], Actor.LAST_NAME],
},
});CarbonNode exports typed builders that return canonical tuples:
import { fn, call, alias, distinct, lit, order } from "@carbonorm/carbonnode";
import { C6, Actor } from "./shared/rest/C6";
const response = await Actor.Get({
[C6.SELECT]: [
alias(distinct(Actor.FIRST_NAME), "distinct_name"),
alias(fn(C6.COUNT, Actor.ACTOR_ID), "cnt"),
call("COALESCE", lit("N/A"), Actor.LAST_NAME),
],
[C6.PAGINATION]: {
[C6.ORDER]: [order(fn(C6.COUNT, Actor.ACTOR_ID), C6.DESC)],
[C6.LIMIT]: 5,
},
});Singular requests (primary key at root) are normalized into complex query format.
await Actor.Get({ [Actor.ACTOR_ID]: 42 });
await Actor.Put({ [Actor.ACTOR_ID]: 42, [Actor.LAST_NAME]: "Updated" });
await Actor.Delete({ [Actor.ACTOR_ID]: 42 });Behavior:
GETwith missing PKs remains a collection query.PUT/DELETEsingular forms require full PK coverage.
If you are manually building URLs, nested arrays must preserve tuple shape exactly.
Common failure:
WHERE[job_runs.job_type][0]=LIT&WHERE[job_runs.job_type][1]=avm_print- This is interpreted as operator tuple
[LIT, "avm_print"], and fails with:Invalid or unsupported SQL operator detected: 'LIT'
Correct structure:
WHERE[job_runs.job_type][0]==WHERE[job_runs.job_type][1][0]=LITWHERE[job_runs.job_type][1][1]=avm_print
Also ensure ORDER is under PAGINATION:
- correct:
PAGINATION[ORDER][0][0]=job_runs.created_at - incorrect:
ORDER[0][0]=job_runs.created_at
Recommendation: use generated bindings + axios executor, not manual URL construction.
generateRestBindings writes:
C6.ts(typed table model + REST bindings)C6.test.ts(generated test suite)C6.mysqldump.sqlC6.mysqldump.jsonC6.mysql.cnf
Template sources:
scripts/assets/handlebars/C6.ts.handlebarsscripts/assets/handlebars/C6.test.ts.handlebars
To enforce a SQL allowlist in production:
import { GLOBAL_REST_PARAMETERS } from "./shared/rest/C6";
GLOBAL_REST_PARAMETERS.sqlAllowListPath = "/path/to/C6.sqlAllowList.json";Optional: add a project-specific normalizer for allowlist matching:
import { GLOBAL_REST_PARAMETERS } from "./shared/rest/C6";
GLOBAL_REST_PARAMETERS.sqlQueryNormalizer = (normalizedSql) =>
normalizedSql.toLowerCase();sqlQueryNormalizer runs after CarbonNode's built-in normalization. Use it to enforce house-style matching (for example lowercase-only allowlist entries).
Allowlist file format:
[
"SELECT * FROM `actor` LIMIT 1"
]Normalization behavior (important):
CarbonNode normalizes both:
- each SQL entry in your allowlist file
- each runtime SQL statement before matching
So you should treat allowlist entries as normalized query shapes, not exact byte-for-byte logs.
Normalization includes:
- stripping ANSI color codes
- collapsing whitespace
- removing trailing
; - normalizing common geo function names (
ST_DISTANCE_SPHERE,ST_GEOMFROMTEXT,MBRCONTAINS) - normalizing
LIMIT/OFFSETnumeric literals to placeholders - collapsing bind groups (for example
IN (?, ?, ?)->IN (? ×*)) - collapsing repeated
VALUESbind rows to a wildcard row shape
Example (conceptual):
-- runtime SQL
SELECT * FROM `actor` WHERE actor.actor_id IN (?, ?, ?) LIMIT 100
-- normalized shape used for matching
SELECT * FROM `actor` WHERE actor.actor_id IN (? ×*) LIMIT ?When enabled:
- missing allowlist file -> error
- SQL not in allowlist -> blocked
Practical workflow:
- Run representative queries in tests/integration.
- Collect normalized SQL statements.
- Save them into your allowlist JSON.
- Set
GLOBAL_REST_PARAMETERS.sqlAllowListPath.
Generated models include lifecycle hook groups per method:
beforeProcessingbeforeExecutionafterExecutionafterCommit
Unlike other CarbonORM language bindings, C6.ts is not semi-persistent. Modifications to the C6 object should be done at runtime, not by editing the generated file.
Websocket payloads for writes are supported via:
GLOBAL_REST_PARAMETERS.websocketBroadcast = async (payload) => {
// broadcast payload to your websocket infrastructure
};HTTP is request/response. A browser asks, server answers, done.
Real-time systems add server push: when data changes, the server proactively notifies connected clients so they can refresh or patch state immediately.
In CarbonNode:
POST,PUT,DELETEcan triggerwebsocketBroadcastGETdoes not broadcast- CarbonNode does not run a websocket server for you
- you provide the
websocketBroadcastfunction and route payloads into your existing websocket layer (ws, Socket.IO, SSE bridge, etc.)
Payload shape (high level):
- table + method metadata
- request body
- request primary key (when detectable)
- response row(s) and response primary key when available
Why this matters:
- keeps multiple tabs/users aligned without polling
- reduces stale UI after writes
- enables event-driven cache invalidation (
table + primary key)
Minimal ws example:
import { WebSocketServer } from "ws";
import { GLOBAL_REST_PARAMETERS } from "./shared/rest/C6";
const wss = new WebSocketServer({ server: httpServer });
GLOBAL_REST_PARAMETERS.websocketBroadcast = async (payload) => {
const message = JSON.stringify({ type: "db.write", payload });
for (const client of wss.clients) {
if (client.readyState === 1) {
client.send(message);
}
}
};Client handling pattern:
- listen for
db.writeevents - inspect
payload.REST.TABLE_NAMEand primary keys - invalidate/refetch relevant queries or patch local state
Run full validation:
npm testThis includes:
- build
- binding generation
- test suite
Before:
[C6.COUNT, Actor.ACTOR_ID, C6.AS, "cnt"]After:
[C6.AS, [C6.COUNT, Actor.ACTOR_ID], "cnt"]Before:
[C6.PAGINATION]: {
[C6.ORDER]: { [Actor.LAST_NAME]: C6.ASC }
}After:
[C6.PAGINATION]: {
[C6.ORDER]: [[Actor.LAST_NAME, C6.ASC]]
}Before:
[C6.ST_GEOMFROMTEXT, ["POINT(-104.89 39.39)", 4326]]After:
[C6.ST_GEOMFROMTEXT, [C6.LIT, "POINT(-104.89 39.39)"], 4326]Use this section as a strict contract for automated query generation.
carbonnode:
version: "6.1.0+"
grammar:
known_function: "[C6C.<KNOWN_FN>, ...args]"
custom_function: "[C6C.CALL, 'FUNCTION_NAME', ...args]"
alias: "[C6C.AS, expression, 'alias']"
distinct: "[C6C.DISTINCT, expression]"
literal: "[C6C.LIT, value]"
order_term: "[expression, 'ASC' | 'DESC']"
required_rules:
- "Bare strings are references only."
- "Wrap non-reference strings with C6C.LIT."
- "PAGINATION.ORDER must be an array of order terms."
- "Use C6C.CALL for unknown function names."
forbidden_legacy:
- "[fn, ..., C6C.AS, alias]"
- "[column, C6C.AS, alias]"
- "{ [C6C.COUNT]: [...] }"
- "Implicit string literals in function args"
- "ORDER object-map syntax"This project uses Git hooks via postinstall:
post-commit: builds projectpost-push: publishes to npm when version changesnpm installrunspostinstallto ensure hooks are configured
Report issues at: