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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ site/public/demo.ts.static
site/public/typegres.js
site/public/typegres.d.ts
packages/
.claude/
15 changes: 15 additions & 0 deletions docs/ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,18 @@

14. ~~**`.live()` subscriptions**~~ — done (PR #72). Predicate extraction,
reverse-index bus, MVCC-snapshot-aware re-iteration.

## SQLite: results stringified, then re-parsed by deserializers

better-sqlite3 already returns typed JS values (number, bigint,
string, Buffer, null), but `SqliteDriver.normalizeRow` stringifies
them all (`String(v)`, blobs → `\x`-hex) to satisfy `deserialize`'s
PG text-protocol contract (`raw: string`) — and the sqlite
deserializers immediately parse them back (`parseInt`, `parseFloat`,
hex → Uint8Array). A pointless double conversion, plus hazards:
int64 values > 2^53 stringify exactly but `parseInt` collapses them
to a lossy double, and blobs copy twice.

Likely fix: widen the deserialize contract to `raw: unknown` (or
per-dialect raw types) so the sqlite driver can pass native values
through and deserializers just narrow/validate.
41 changes: 41 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
},
"scripts": {
"build": "tsdown",
"codegen": "node --experimental-strip-types src/types/postgres/generate.ts && node --experimental-strip-types src/types/sqlite/generate.ts",
"codegen:check": "tmp=$(mktemp -d) && node --experimental-strip-types src/types/postgres/generate.ts --out-dir \"$tmp/pg\" && { diff -r \"$tmp/pg\" src/types/postgres/generated || { echo 'src/types/postgres/generated is stale — run `npm run codegen` and commit.' >&2; rm -rf \"$tmp\"; exit 1; }; } && node --experimental-strip-types src/types/sqlite/generate.ts --out-dir \"$tmp/sqlite\" && { diff -r \"$tmp/sqlite/generated\" src/types/sqlite/generated || { echo 'src/types/sqlite/generated is stale — run `npm run codegen` and commit.' >&2; rm -rf \"$tmp\"; exit 1; }; } && rm -rf \"$tmp\"",
"codegen": "node --experimental-strip-types src/types/postgres/emit.ts && node --experimental-strip-types src/types/sqlite/emit.ts",
"codegen:check": "tmp=$(mktemp -d) && node --experimental-strip-types src/types/postgres/emit.ts --out-dir \"$tmp/pg\" && { diff -r \"$tmp/pg\" src/types/postgres/generated || { echo 'src/types/postgres/generated is stale — run `npm run codegen` and commit.' >&2; rm -rf \"$tmp\"; exit 1; }; } && node --experimental-strip-types src/types/sqlite/emit.ts --out-dir \"$tmp/sqlite\" && { diff -r \"$tmp/sqlite/generated\" src/types/sqlite/generated || { echo 'src/types/sqlite/generated is stale — run `npm run codegen` and commit.' >&2; rm -rf \"$tmp\"; exit 1; }; } && rm -rf \"$tmp\"",
"lint": "eslint src",
"typecheck": "tsgo --noEmit",
"format": "prettier --write src",
Expand All @@ -76,6 +76,7 @@
"acorn": "^8.16.0",
"better-sqlite3": "^12.11.1",
"eslint": "^10.2.1",
"fast-check": "^4.9.0",
"pg": "^8.20.0",
"prettier": "^3.8.3",
"secure-json-parse": "^4.1.0",
Expand Down
2 changes: 1 addition & 1 deletion src/builder/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Connection } from "../database";
// dispatches per-dialect (Phase 2.1) these will be gated by ctx.dialect.
import { Anyarray, Record } from "../types/postgres";
// Dialect-agnostic base for shape/predicate/instanceof checks — accepts
// PG's Any or SQLite's SqliteValue uniformly.
// PG's Any or SQLite's Any uniformly.
import { SqlValue } from "../types/sql-value";
import { zBool, type Bool } from "../types/bool";
import { type TsTypeOf, type Nullable, type AggregateRow } from "../types/runtime";
Expand Down
15 changes: 14 additions & 1 deletion src/builder/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,20 @@ export const compile = (root: Sql, ctx: CompileContext): CompiledSql => {
} else if (atom instanceof Ident) {
out.push(quoteIdent(atom.name));
} else if (atom instanceof Param) {
values.push(atom.value);
// Raw-param semantics for sqlite are the JS ones: a JS number IS
// a double, so it binds as REAL (`sql\`${7} / ${2}\`` is 3.5, and
// typeof(${7}) is 'real'); BigInt binds as INTEGER. Typed
// positions are different — methods and table columns serialize
// through their class (CAST(? AS INTEGER/...)), so storage
// classes there follow the claim, not the binding.
// Booleans: SQLite has no boolean type — the convention is 0/1
// INTEGER (BigInt so it binds as INTEGER, not REAL). A dialect
// fact, so it lives here in compilation rather than in any one
// driver.
const value = ctx.database.dialect === "sqlite" && typeof atom.value === "boolean"
? (atom.value ? 1n : 0n)
: atom.value;
values.push(value);
out.push(paramForDialect(ctx.database.dialect, values.length));
} else if (atom instanceof Alias) {
const resolved = scope.resolve(atom);
Expand Down
2 changes: 1 addition & 1 deletion src/tables/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { pgNameToClassName } from "../types/postgres/introspect.ts";
export interface ColumnInfo {
name: string;
// Resolved TS class name for the type (e.g. "Int8", "Text",
// "Integer", "Bool", "SqliteValue"). The introspector resolves this
// "Integer", "Bool", "Any"). The introspector resolves this
// at introspection time — shared codegen doesn't know how PG
// typnames vs SQLite affinity resolve.
className: string;
Expand Down
20 changes: 10 additions & 10 deletions src/tables/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ describe("affinityToClass — SQLite type affinity rules", () => {
test("BOOLEAN → Bool (overlay before affinity)", () => { expect(affinityToClass("BOOLEAN")).toBe("Bool"); });
test("bool → Bool (case-insensitive)", () => { expect(affinityToClass("bool")).toBe("Bool"); });

// NUMERIC affinity fall-through → SqliteValue (no narrow view).
test("DATE → SqliteValue (NUMERIC affinity, no domain type)", () => {
expect(affinityToClass("DATE")).toBe("SqliteValue");
// NUMERIC affinity fall-through → Any (no narrow view).
test("DATE → Any (NUMERIC affinity, no domain type)", () => {
expect(affinityToClass("DATE")).toBe("Any");
});
test("DATETIME → SqliteValue", () => { expect(affinityToClass("DATETIME")).toBe("SqliteValue"); });
test("DECIMAL(10,2) → SqliteValue", () => { expect(affinityToClass("DECIMAL(10,2)")).toBe("SqliteValue"); });
test("NUMERIC → SqliteValue", () => { expect(affinityToClass("NUMERIC")).toBe("SqliteValue"); });
test("DATETIME → Any", () => { expect(affinityToClass("DATETIME")).toBe("Any"); });
test("DECIMAL(10,2) → Any", () => { expect(affinityToClass("DECIMAL(10,2)")).toBe("Any"); });
test("NUMERIC → Any", () => { expect(affinityToClass("NUMERIC")).toBe("Any"); });

// Classic SQLite affinity gotchas — we match SQLite's own behavior.
test("FLOATING POINT → Integer (rule 1 catches 'POINT' → 'INT')", () => {
Expand All @@ -48,8 +48,8 @@ describe("affinityToClass — SQLite type affinity rules", () => {
test("CHARINT → Integer (rule 1 beats rule 2)", () => {
expect(affinityToClass("CHARINT")).toBe("Integer");
});
test("STRING → SqliteValue (no matching affinity rule)", () => {
expect(affinityToClass("STRING")).toBe("SqliteValue");
test("STRING → Any (no matching affinity rule)", () => {
expect(affinityToClass("STRING")).toBe("Any");
});
});

Expand Down Expand Up @@ -109,7 +109,7 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => {
expect(files.get("dogs")).toMatchInlineSnapshot(`
"import { db } from "../db";
import { expose, sql } from "typegres";
import { Bool, Integer, SqliteValue, Text } from "typegres/sqlite";
import { Any, Bool, Integer, Text } from "typegres/sqlite";
import { Teams } from "./teams";
export class Dogs extends db.Table("dogs") {
Expand All @@ -119,7 +119,7 @@ describe("sqlite codegen e2e — DDL in, generated TypeScript out", () => {
@expose() breed = (Text<0 | 1>).column();
@expose() team_id = (Integer<1>).column({ nonNull: true });
@expose() active = (Bool<1>).column({ nonNull: true, default: sql\`0\` });
@expose() seen_at = (SqliteValue<0 | 1>).column();
@expose() seen_at = (Any<0 | 1>).column();
// relations
@expose() team() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); }
// @generated-end
Expand Down
6 changes: 3 additions & 3 deletions src/tables/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ interface IndexInfoRow {
// "affinity" via ordered substring rules (§ 3.1 of the SQLite type
// docs). We follow those rules 1:1 plus a small overlay for BOOLEAN
// (a strong-enough convention to surface as our `Bool`). NUMERIC
// affinity resolves to `SqliteValue` — the base class with no narrow
// affinity resolves to `Any` — the base class with no narrow
// method surface, which honestly reflects SQLite's dynamic typing.
export const affinityToClass = (declaredType: string): string => {
// BOOL/BOOLEAN overlay first — affinity rules would send this to
Expand All @@ -63,10 +63,10 @@ export const affinityToClass = (declaredType: string): string => {
if (/REAL|FLOA|DOUB/.test(t)) { return "Real"; }

// NUMERIC fallback — DATE, DATETIME, TIMESTAMP, DECIMAL, MONEY, and
// custom names all land here. Bare SqliteValue<N>: no narrow
// custom names all land here. Bare Any<N>: no narrow
// methods, `unknown` on hydration. User asserts intent at the call
// site when treating it as a specific type.
return "SqliteValue";
return "Any";
};

// `INTEGER PRIMARY KEY` (that exact declared type, not "INT") in a
Expand Down
39 changes: 39 additions & 0 deletions src/types/emission/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Shared naming DATA for the unified emitter (../emission/emit.ts):
// operator-symbol aliases. Both dialects emit the same model —
// runtime.match dispatch under @expose.unchecked, camelCase function
// names, operator symbols as bracket methods plus readable aliases.


// Readable aliases for operator symbols (the catalogs don't provide
// nice names). Emitted as a second method next to the bracketed form:
// ['=']<M>(arg: M): Bool<...> // symbol form
// eq<M>(arg: M): Bool<...> // alias
// Dialect-shared with PG's collision rule: the alias is SKIPPED when
// a real function already owns the name (pg: mod/pow exist as catalog
// functions; sqlite: mod()) — the function's signature is
// authoritative.
export const OPERATOR_ALIASES: { [op: string]: string } = {
"=": "eq",
"<>": "ne",
"<": "lt",
"<=": "lte",
">": "gt",
">=": "gte",
"+": "plus",
"-": "minus",
"*": "times",
"/": "divide",
"%": "mod",
"^": "pow",
};

// Unary operators are alias-only where the bracket form would collide
// (unary "-" vs binary minus); "~" keeps its bracket form too.
export const UNARY_OPERATOR_ALIASES: { [op: string]: string } = {
"-": "negate",
"~": "bitNot",
};




Loading
Loading