Skip to content

Commit 83e7f15

Browse files
ryanrasticlaude
andcommitted
feat(sqlite): Table + QueryBuilder work end-to-end over SQLite
Widens the query/mutation builders from PG-only (`instanceof Any`) to dialect-agnostic (`instanceof SqlValue`) across ~15 runtime dispatch points + ~20 type refs. PG callers unchanged — Any still is-a SqlValue. Changes by file: - src/table.ts, src/util.ts, src/builder/{values,insert,update,query}.ts: Any → SqlValue at instanceof checks + type positions. `isColumn` / `getColumn` imports moved from postgres/overrides/any (re-export) to src/types/any (source). - src/types/runtime.ts: `NullOf`, `TsTypeOf`, `Aggregate`, `AggregateRow` widened from `T extends Any<...>` to `T extends SqlValue<...>` so the utilities work uniformly over PG's Any and SQLite's SqliteValue. - src/types/bool.ts: the shared `Bool<N>` interface loses `and`/`or`/`not` and becomes a nominal marker. Concrete Bool classes' `.and(other: X <any> | boolean)` are contravariantly incompatible with a shared method signature — trying to require the ops here breaks assignment from concrete to shared. Chaining callers cast through `any` (see `combinePredicates`); runtime validation still uses the `isBool` identity predicate. - src/types/meta.ts (new): extracted the `meta` symbol into its own tiny module. `types/any.ts` used to import `meta` from `runtime.ts`, but `runtime.ts` also does `import * as types from "./index"` which triggers the PG barrel chain — extending SqlValue via PG's generated any before any.ts had defined it. Splitting `meta` out breaks the cycle. - src/driver.ts (SqliteDriver): unwraps a single matched outer paren pair from compiled SQL. `QueryBuilder.FinalizedQuery.bind()` wraps its output in `(...)` for subquery-splicing; PG tolerates a parenthesized top-level, SQLite does not. - src/builder/{delete,update,query}.ts: `@expose` validators swapped from `z.instanceof(Bool)` (PG-only) to `z.custom<SharedBool<any>> (isBool)` (dialect-agnostic via the identity predicate). Note: the broader "same-database provenance" concern is filed as ISSUES.md #16; this changes the check from PG-nominal to dialect-nominal (catches cross-dialect but not cross-tenant). - src/types/sqlite/table.test.ts (new): 7 end-to-end tests through the full Table → QueryBuilder → SqliteDriver stack — INSERT, SELECT with method composition (.upper()), .where predicates, INSERT/UPDATE/ DELETE ... RETURNING. Deferred (documented as comments in place, not blocking Phase 1): - QueryBuilder.scalar() stays PG-only — emits ROW() + array_agg + COALESCE(..., '{}'). Phase 2.1 Scalar AST node lands the SQLite dispatch (json_object + json_group_array). - `.where(true)` shorthand in delete/update still constructs a PG `Bool` — sqlite-only apps that need this will hit a nominal mismatch until the shorthand becomes dialect-aware. Test totals: 28 files, 550 passed, 1 skipped (was 27/544). PG suite green throughout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 47e80be commit 83e7f15

15 files changed

Lines changed: 343 additions & 108 deletions

File tree

docs/ISSUES.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,51 @@
9595

9696
14. ~~**`.live()` subscriptions**~~ — done (PR #72). Predicate extraction,
9797
reverse-index bus, MVCC-snapshot-aware re-iteration.
98+
99+
16. **Cross-database provenance for typed values (RPC security).** Runtime
100+
validators today check `v instanceof SqlValue` — "is this a typegres
101+
value?" but not "*whose* typegres value?" Over RPC (exoeval), a client
102+
can construct a `Bool` (or any typed expression) and hand it to
103+
`db.execute(qb.where(theirBool))`. The check passes if the object is
104+
a proper SqlValue instance; nothing verifies it was built *for the
105+
Database the RPC handler is bound to*.
106+
107+
Threats:
108+
109+
- **Cross-dialect smuggling:** attacker on a SQLite-backed session
110+
injects a Bool constructed against a Postgres schema. SQL emission
111+
collides (`?` vs `$N` placeholders, wrong typenames). Coarsely
112+
addressed by the dialect check that comes with the `Any``SqlValue`
113+
sweep (Table+QB dialect-agnostic work), because `v.constructor.dialect.name`
114+
is authoritative per class.
115+
- **Cross-tenant smuggling** (same dialect, different DB instances):
116+
values from tenant B's schema get spliced into tenant A's query.
117+
Column names / OIDs matching by coincidence → info leak. Dialect
118+
check does *not* catch this.
119+
- **Cross-session smuggling** within one Database: some values carry
120+
session-scoped state via `db.scope(principal)`. Bypassing the tag =
121+
privilege escalation.
122+
123+
Design options:
124+
125+
- **Instance-scoped tagging.** Each `Database` mints a `Symbol()`;
126+
values built through a db-scoped factory (`db.Int4.from(5)` instead
127+
of `Int4.from(5)`) carry that tag. Runtime checks `v[dbIdKey] === this.dbId`.
128+
Catches all three. Cost: breaking API change for existing PG callers;
129+
RPC serialization needs to preserve the tag across the wire.
130+
- **Scope-only.** Don't tag values; validate the compiled Sql tree at
131+
`db.execute()` time — walk it and reject any SqlValue whose dialect
132+
doesn't match ctx. Cheap, addresses cross-dialect only.
133+
- **Opt-in session tagging.** Untagged values keep working (interop).
134+
`db.scope(session).typedValue(x)` explicitly binds. Session-sensitive
135+
methods (mutation, sensitive-table `.where`) refuse untagged values.
136+
Backwards-compatible; enforcement lives where the security matters.
137+
138+
Operator/method checks have the same story — `int.plus(other)` today
139+
accepts any Int4-ish arg; under instance-tagging, `runtime.match()`
140+
would enforce the tag matches `this`. Single code path to protect.
141+
142+
Not blocking Phase 1 (SQLite dialect work), but *is* blocking any RPC
143+
production deployment with multi-tenant or cross-dialect setups. Fold
144+
into the exoeval hardening pass (relates to #13 gas accounting — both
145+
are RPC-boundary threats).

src/builder/delete.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Sql, sql, Alias, compile, pgCtx, type CompileContext } from "./sql";
22
import type { BoundSql } from "./sql";
33
import { Bool } from "../types";
4+
import { isBool, type Bool as SharedBool } from "../types/bool";
45
import type { RowType, RowTypeToTsType } from "./query";
56
import { combinePredicates, compileSelectList, isRowType, mergeReturning, reAlias } from "./query";
67
import type { TableBase } from "../table";
@@ -12,7 +13,7 @@ type Namespace<Name extends string, T> = { [K in Name]: T };
1213

1314
type DeleteOpts<Name extends string, T extends TableBase, R extends RowType> = {
1415
instance: T;
15-
where?: (ns: Namespace<Name, T>) => Bool<any>;
16+
where?: (ns: Namespace<Name, T>) => SharedBool<any>;
1617
returning?: (ns: Namespace<Name, T>) => R;
1718
};
1819

@@ -23,7 +24,7 @@ type FinalizedDeleteOpts<Name extends string, T extends TableBase, R extends Row
2324
tableName: Name;
2425
alias: Alias;
2526
instance: T;
26-
where: Bool<any>;
27+
where: SharedBool<any>;
2728
returning?: R;
2829
};
2930

@@ -59,10 +60,10 @@ export class DeleteBuilder<Name extends string, T extends TableBase, R extends R
5960
}
6061

6162
// Multiple where() calls are combined with AND. .where(true) matches all rows.
62-
@expose(z.union([z.literal(true), fn.returns(z.lazy(() => z.instanceof(Bool)))]))
63-
where(fn: ((ns: Namespace<Name, T>) => Bool<any>) | true): DeleteBuilder<Name, T, R> {
64-
const wrapped: (ns: Namespace<Name, T>) => Bool<any> =
65-
fn === true ? () => Bool.from(sql`TRUE`) as Bool<any> : fn;
63+
@expose(z.union([z.literal(true), fn.returns(z.custom<SharedBool<any>>(isBool))]))
64+
where(fn: ((ns: Namespace<Name, T>) => SharedBool<any>) | true): DeleteBuilder<Name, T, R> {
65+
const wrapped: (ns: Namespace<Name, T>) => SharedBool<any> =
66+
fn === true ? () => Bool.from(sql`TRUE`) as SharedBool<any> : fn;
6667
return new DeleteBuilder({
6768
...this.#opts,
6869
where: combinePredicates(this.#opts.where, wrapped),

src/builder/insert.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { RowType, RowTypeToTsType } from "./query";
44
import { compileSelectList, isRowType, mergeReturning, reAlias } from "./query";
55
import type { TableBase } from "../table";
66
import { Database } from "../database";
7-
import { getColumn } from "../types/postgres/overrides/any";
7+
import { getColumn } from "../types/any";
88
import { meta } from "../types/runtime";
99
import { fn, expose } from "../exoeval/tool";
1010
import z from "zod";

src/builder/query.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,11 @@ test("where defers callback validation — bad return only throws at compile", (
834834
.values({ a: Int4.from(1) })
835835
// @ts-expect-error — callback must return Bool
836836
.where(() => 42);
837-
expectReturnValidationError(() => compile(q, pgCtx), /expected Bool, received number/);
837+
// With the shared `isBool` predicate (`z.custom(isBool)`), Zod emits a
838+
// generic `Invalid input` message. The typed `expected Bool, received
839+
// number` output was specific to `z.instanceof(Bool)`; there's no way
840+
// to recover the same message via z.custom without a message override.
841+
expectReturnValidationError(() => compile(q, pgCtx), /Invalid input/);
838842
});
839843

840844
test("orderBy defers — empty array fails .min(1) at compile", () => {

0 commit comments

Comments
 (0)