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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ clients/ts/dist/
# Bench artifacts
crates/compare/results.csv

# agent-eval seeded golden data + per-run scoring output (recreated by setup.sh)
scripts/agent-eval/.golden-data/
scripts/agent-eval/results.json
scripts/agent-eval/examples/results.json

# Smoke-audit screenshots (regenerated on demand)
/landing-desktop.png
/landing-mobile.png
Expand Down
24 changes: 19 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Compare SQL: `SELECT name, age FROM User WHERE age > 25 ORDER BY age DESC LIMIT
| Add a column | `alter User add column status: str` | `ALTER TABLE User ADD COLUMN status TEXT` |
| Drop a column | `alter User drop column status` | `ALTER TABLE User DROP COLUMN status` |
| Create an index | `alter User add index .email` | `CREATE INDEX ON User (email)` |
| Unique column | `type User { unique email: str }` | `CREATE TABLE User (email TEXT UNIQUE)` |
| Add unique constraint | `alter User add unique .email` | `CREATE UNIQUE INDEX ON User (email)` |
| Insert | `insert User { name := "Alice", age := 30 }` | `INSERT INTO User (name, age) VALUES ('Alice', 30)` |
| Scan a table | `User` | `SELECT * FROM User` |
| Filter | `User filter .age > 30` | `SELECT * FROM User WHERE age > 30` |
Expand All @@ -88,7 +90,7 @@ Compare SQL: `SELECT name, age FROM User WHERE age > 25 ORDER BY age DESC LIMIT
| Update | `User filter .id = 1 update { age := 31 }` | `UPDATE User SET age = 31 WHERE id = 1` |
| Update with expr | `User update { age := .age + 1 }` | `UPDATE User SET age = age + 1` |
| Delete | `User filter .age < 18 delete` | `DELETE FROM User WHERE age < 18` |
| Upsert | `upsert User on .id { id := 1, name := "Alice" }` | `INSERT ... ON CONFLICT (id) DO UPDATE ...` |
| Upsert (key must be `unique`) | `upsert User on .id { id := 1, name := "Alice" }` | `INSERT ... ON CONFLICT (id) DO UPDATE ...` |
| CASE | `case when .age > 30 then "old" else "young" end` | `CASE WHEN age > 30 THEN 'old' ELSE 'young' END` |
| Materialized view | `materialize OldUsers as User filter .age > 28` | `CREATE MATERIALIZED VIEW OldUsers AS ...` |

Expand Down Expand Up @@ -119,15 +121,17 @@ Canonical type names: `str`, `int`, `float`, `bool`, `datetime`, `uuid`, `bytes`

**Footgun:** the executor's type resolver falls back to `TypeId::Str` for any unknown name (`crates/query/src/executor/`), so `string`, `varchar`, or a typo silently produces a Str column with no error. Always use the canonical names above.

`required` is a prefix keyword on the field, not a `!` suffix: `required name: str`, never `name: str!`.
`required` is a prefix keyword on the field, not a `!` suffix: `required name: str`, never `name: str!`. `unique` is a sibling prefix keyword (`required unique email: str`, either order) that auto-creates a unique B+tree index and enforces no duplicate non-null values on insert/update/upsert.

**Footgun (since 0.4.7):** `upsert <T> on .<col>` requires `.col` to be **unique** — declare it `unique` in the `type`, or run `alter <T> add unique .<col>` first. Upserting on a non-unique column is now a hard error (this closed a bug where upsert could silently create duplicate keys). `alter add unique` first scans for existing duplicates and fails if any are present; it also rejects a column that already has a non-unique index (no in-place upgrade). Null values are exempt from `unique`.

---

## Why PowDB is fast (the short version)

These are the design moves that buy the speedup. Understanding them keeps you from accidentally undoing them:

1. **Planner is a pure function.** It does not touch the catalog — it emits `RangeScan` speculatively, and the executor lowers to `Filter(SeqScan)` at runtime if no index exists. This keeps the parser → plan pipeline allocation-free for cache hits.
1. **Planner is a pure function.** It does not touch the catalog — it emits `RangeScan`/`IndexScan` speculatively. The executor lowers them to `Filter(SeqScan)` at runtime only when no index exists on the column; otherwise it walks the B+tree directly (unique indexes: raw column-value keys; non-unique indexes: composite `(value, rid)` keys via `BTree::range_rids`, heap-fetching matched rows and rechecking exclusive bounds). This keeps the parser → plan pipeline allocation-free for cache hits.
2. **Plan cache hashes canonical PowQL.** Literals are substituted at lookup time (FNV-1a hash, `crates/query/src/plan_cache.rs`). A repeated `User filter .id = <N>` reuses the same plan for all N.
3. **Compiled integer predicates.** `Filter(SeqScan)` on simple numeric predicates compiles into a branch-free byte-level check that skips full row decoding. See `execute_plan` fast paths in `crates/query/src/executor/` (module dir).
4. **mmap-based scans.** The storage layer exposes `try_for_each_row_raw` over memory-mapped heap files. Early termination is a `return ControlFlow::Break`.
Expand Down Expand Up @@ -158,7 +162,7 @@ cargo run --release -p powdb-cli # embedded REPL
cargo run --release -p powdb-cli -- --remote host:5433 --password <pw>
```

**The REPL is line-oriented.** A statement split across lines fails to parse — write each statement on one line.
**The REPL buffers lines until braces/parens balance** — multi-line `type`/`insert` paste works; a statement still cannot span two separately-submitted balanced lines.

### TCP server

Expand All @@ -183,7 +187,17 @@ if (r.kind === "rows") console.table(r.rows);
await client.close();
```

**No parameter binding yet.** If your input is untrusted, escape it yourself; we don't have prepared-statement placeholders over the wire.
**Parameter binding (`$1`..`$N`).** Pass untrusted values as positional parameters instead of interpolating them into the query string. Placeholders are 1-based `$N` (not `?` — `??` is the COALESCE operator). Binding happens at the token level on the server: each `$N` is replaced with the literal token for the matching value before parsing, so an injection-shaped string is inert data and can never change the query's shape.

```ts
// Values pass as the second argument, in $1, $2, … order.
await client.query("insert User { name := $1, email := $2, age := $3 }", [name, email, age]);
const r = await client.query("User filter .email = $1 { .name }", [email]);
// null binds PowQL null; numbers bind int when integral, float otherwise.
await client.query("insert User { name := $1, age := $2 }", ["Dana", null]);
```

The params form uses the `QueryWithParams` (0x04) wire message and requires `powdb-server >= 0.4.7`. The plain no-params `query(q)` form is unchanged.

Return shapes:
- `{ kind: "rows", columns: string[], rows: string[][] }` — SELECT-like queries
Expand Down
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Parameter binding over the wire (`$1`..`$N`).** Clients can send a query
template plus positional values instead of interpolating untrusted input
into the query string. Placeholders are 1-based `$N` (not `?` — `??` is the
COALESCE operator). Binding happens at the **token level** on the server:
each `$N` is replaced with the literal token for its value before parsing,
so an injection-shaped string is inert data and can never change the query's
shape. New wire message `QueryWithParams` (`0x04`) — a pure protocol
addition; existing messages and pre-0.4.7 clients are unaffected. The
TypeScript client gains `client.query(powql, params?)`. Engine API:
`Engine::execute_powql_with_params` / `execute_powql_readonly_with_params`.
- **Unique constraints.** Declare a column unique with the `unique` field
modifier (`type User { required unique email: str }`) or add one to an
existing table with `alter User add unique .email` (which scans for existing
duplicates first and fails if any exist). Declaring `unique` auto-creates a
unique B+tree index; enforcement is a storage-layer pre-check shared by the
plain, prepared, and upsert write paths, so duplicates are rejected with
`unique constraint violation on <table>.<column>` before anything is written
or WAL-logged. The constraint survives restart (persisted in the catalog +
rebuilt on WAL replay).
- **Range scans use B+tree indexes.** `>`, `>=`, `<`, `<=`, and `between` on an
indexed column now traverse the index (unique: raw keys; non-unique:
composite `(value, rid)` keys) instead of always falling back to a full
scan — roughly 7× faster on a selective range over 100K rows. NULLs are
correctly excluded and exclusive bounds are honored.
- **`EXPLAIN` shows the executed plan.** Because the planner is pure (no
catalog access), it emits speculative `IndexScan`/`RangeScan` nodes; the
executor lowers them at runtime when no index exists. `EXPLAIN` now applies
the same lowering before printing, so it shows `Filter(SeqScan)` for an
unindexed column instead of a misleading `IndexScan`.
- **Multi-line REPL input.** The `powdb-cli` REPL buffers lines until braces
and parentheses balance (outside string literals), so multi-line `type` and
`insert` statements can be pasted or typed across lines.
- **Agent-DX evaluation harness** (`scripts/agent-eval/`). A model-agnostic,
offline harness that scores how well an LLM writes PowQL given only
`AGENTS.md` and a 10-table schema, with a parallel SQLite baseline for
comparison. Not wired into CI.

### Changed
- **BREAKING:** `upsert <T> on .col` now requires `.col` to be a `unique`
column. Declare it with the `unique` modifier or `alter <T> add unique .col`.
This fixes a bug where `upsert ... on .id` followed by a plain
`insert` of the same id silently produced duplicate rows.

### Fixed
- Lowering an unindexed equality update to `Filter(SeqScan)` exposed a fused
scan-update path that swallowed `update_hinted` errors and still counted the
row as modified — which bypassed the v0.4.6 oversized-row guard for that
path. Errors now propagate as `StorageError`; all three `oversized_rows`
tests pass.

## [0.4.6] - 2026-06-09

### Fixed
Expand Down
25 changes: 24 additions & 1 deletion clients/ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,27 @@ escapeIdent("User"); // → "User" (throws on invalid)

`escapeLiteral` accepts `string | number | bigint | boolean | null`. It rejects `NaN`/`Infinity`, `undefined`, objects, arrays, symbols, and `Date` — convert those yourself before passing them in.

### Parameter binding (`$N`)

For the strongest separation between code and data, pass values as positional `$N` parameters instead of interpolating them. The server binds each placeholder at the **token level** — a string becomes a literal token, never interpolated text — so an injection-shaped value is inert and can never change the query's shape. Placeholders are 1-based (`?` is not a placeholder; `??` is the COALESCE operator).

```typescript
// Values are passed as the second argument, in $1, $2, … order.
await client.query("insert User { name := $1, email := $2, age := $3 }", [
name,
email,
age,
]);

const r = await client.query("User filter .email = $1 { .name }", [email]);

// null binds PowQL null; numbers bind as int when integral, float otherwise;
// bigint always binds as int.
await client.query("insert User { name := $1, age := $2 }", ["Dana", null]);
```

`QueryParam` is `string | number | bigint | boolean | null`. The params form sends the `QueryWithParams` (0x04) wire message and **requires powdb-server >= 0.4.7**. The plain `query(q)` and `query(q, { signal })` forms are unchanged.

## Authentication

For servers using the legacy shared password (`POWDB_PASSWORD`), pass
Expand Down Expand Up @@ -277,14 +298,16 @@ Returns a `Promise<Client>`. Options:
> **Multi-user servers:** requires client ≥0.4.0 (`user` option) and server
> ≥0.4.6 (enforced roles). See the version matrix under Authentication.

### `client.query(query, opts?)`
### `client.query(query, params?, opts?)`

Sends a PowQL query and returns a `Promise<QueryResult>`:

- `{ kind: "rows", columns: string[], rows: string[][] }` — for SELECT-like queries
- `{ kind: "scalar", value: string }` — for aggregates (`count`, `sum`, `avg`, etc.)
- `{ kind: "ok", affected: bigint }` — for mutations (`insert`, `update`, `delete`)

`params?: QueryParam[]` — positional values bound to `$1`, `$2`, … placeholders (see Parameter binding above; requires server ≥0.4.7). When omitted, the plain query path is used. The legacy two-argument `query(q, { signal })` form is still accepted — an array second argument is treated as params, an object as options.

`opts.signal?: AbortSignal` — aborts the returned promise (see Cancellation above).

Throws a `PowDBError` (see Structured errors above) on any failure.
Expand Down
62 changes: 58 additions & 4 deletions clients/ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
import * as net from "node:net";
import * as tls from "node:tls";
import { EventEmitter } from "node:events";
import { encode, tryDecode, type Message } from "./protocol.js";
import {
encode,
tryDecode,
type Message,
type WireParam,
} from "./protocol.js";
import { PowDBError } from "./errors.js";
import {
coerceRows,
Expand All @@ -35,6 +40,38 @@ export type QueryResult =
| { kind: "ok"; affected: bigint }
| { kind: "message"; message: string };

/**
* A value bound to a positional `$N` placeholder in {@link Client.query}.
*
* The server binds these at the token level — a string is substituted as a
* literal token, never interpolated — so injection-shaped input is inert.
* Numbers bind as ints when integral and floats otherwise; `bigint` always
* binds as an int; `null` binds PowQL `null`.
*/
export type QueryParam = string | number | bigint | boolean | null;

/** Map a JS {@link QueryParam} to its wire encoding. */
function toWireParam(p: QueryParam): WireParam {
if (p === null) return { tag: "null" };
switch (typeof p) {
case "string":
return { tag: "str", value: p };
case "boolean":
return { tag: "bool", value: p };
case "bigint":
return { tag: "int", value: p };
case "number":
return Number.isInteger(p)
? { tag: "int", value: BigInt(p) }
: { tag: "float", value: p };
default:
throw new PowDBError(
`unsupported query parameter type: ${typeof p}`,
"protocol_error",
);
}
}

export interface ClientOptions {
host: string;
port: number;
Expand Down Expand Up @@ -235,11 +272,27 @@ export class Client extends EventEmitter<ClientEvents> {
*/
async query(
query: string,
opts?: { signal?: AbortSignal },
paramsOrOpts?: QueryParam[] | { signal?: AbortSignal },
maybeOpts?: { signal?: AbortSignal },
): Promise<QueryResult> {
// Disambiguate the two overloads:
// query(q) — no params, no opts
// query(q, opts) — legacy 2-arg opts form (back-compat)
// query(q, params) — positional $N parameters
// query(q, params, opts) — params + opts
const hasParams = Array.isArray(paramsOrOpts);
const params = hasParams ? (paramsOrOpts as QueryParam[]) : undefined;
const opts = hasParams
? maybeOpts
: (paramsOrOpts as { signal?: AbortSignal } | undefined);

const start = Date.now();
try {
const reply = await this.send({ type: "Query", query }, opts);
const request: Message =
params === undefined
? { type: "Query", query }
: { type: "QueryWithParams", query, params: params.map(toWireParam) };
const reply = await this.send(request, opts);
let result: QueryResult;
switch (reply.type) {
case "ResultRows":
Expand Down Expand Up @@ -638,11 +691,12 @@ function openSocket(
}

export { encode, tryDecode } from "./protocol.js";
export type { Message } from "./protocol.js";
export type { Message, WireParam } from "./protocol.js";
export {
MAX_PAYLOAD_SIZE,
MAX_ROWS,
MAX_COLUMNS,
MAX_PARAMS,
} from "./protocol.js";

export {
Expand Down
Loading
Loading