From 249dfd4faea79b4d5bbe7c371229e1d7b302dead Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Mon, 12 Oct 2026 18:38:43 -0400 Subject: [PATCH] fix: generate native SQLite registered query value types --- CHANGELOG.md | 3 + docs/adapters.md | 10 +++ src/tooling-impl.ts | 9 ++- src/tooling-integration.test.ts | 8 ++- src/tooling.test.ts | 112 ++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8681e..8040bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ ### Fixed +- Generate SQLite registered-query fields as native number/string/blob/null + unions instead of PostgreSQL numeric strings and booleans. SQLite affinities + permit mixed storage classes; callers must narrow generated fields before use. - SQLite migration execution no longer drops all statements after the first. - PostgreSQL migration scripts no longer fail when the driver returns multiple result sets. diff --git a/docs/adapters.md b/docs/adapters.md index 512eac4..4314e90 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -23,6 +23,16 @@ The runtime and tooling adapters are deliberately separate. An adapter must pass values to the driver separately from SQL text and never interpolate application data. Placeholder rendering is dialect-owned. +Registered queries return adapter values without decoding or coercion. Generated +SQLite row fields use `number | string | Uint8Array | null`: SQLite column +declarations specify affinity rather than enforce a storage class, so even +`numeric`, `bigint`, and `boolean` columns may contain numbers, text, blobs, or +null. The built-in `node:sqlite` adapter uses number reads for integers; it does +not return JavaScript booleans, bigints, or dates. Integers outside the safe +JavaScript number range raise a driver error. Narrow or validate generated fields +before using a specific value type. Generated PostgreSQL fields follow the +standard `pg` parsers described in [PostgreSQL tooling](postgres-tooling.md). + `DatabaseToolingAdapter` is allowed only in tooling: - `identity` is a stable, non-secret database identity. diff --git a/src/tooling-impl.ts b/src/tooling-impl.ts index a16f122..56d793f 100644 --- a/src/tooling-impl.ts +++ b/src/tooling-impl.ts @@ -700,6 +700,12 @@ async function describeRegisteredQueries( } function tsType(dataType: string, nullable: boolean, dialect: "postgres" | "sqlite"): string { + // SQLite declarations describe affinity, not enforced storage classes. Registered + // queries return node:sqlite values unchanged, with bigint reads disabled. + if (dialect === "sqlite") { + const base = "number | string | Uint8Array"; + return nullable ? `${base} | null` : base; + } const normalized = dataType.toLowerCase(); const base = normalized === "boolean" @@ -710,8 +716,7 @@ function tsType(dataType: string, nullable: boolean, dialect: "postgres" | "sqli ? "string" : normalized === "bytea" ? "Uint8Array" - : dialect === "postgres" && - /^(?:date|timestamp(?:\(\d+\))? (?:with|without) time zone)$/.test(normalized) + : /^(?:date|timestamp(?:\(\d+\))? (?:with|without) time zone)$/.test(normalized) ? "Date" : /^(?:json|jsonb)$/.test(normalized) ? "unknown" diff --git a/src/tooling-integration.test.ts b/src/tooling-integration.test.ts index 2d90f05..4b3173a 100644 --- a/src/tooling-integration.test.ts +++ b/src/tooling-integration.test.ts @@ -259,7 +259,7 @@ const entries = table("entries", { lowerEmail: text().generatedAlwaysAs("lower(email)"), amount: numeric(10, 2).default("0.00"), data: bytes(), }, { schema: "catalog #%", constraints: [check("length(email) > 0", "email_nonempty"), index(["lower(email)"], { name: "lower_email", where: "state IS NOT NULL" })] }); const emails = view("emails", 'SELECT email FROM "catalog #%".entries', { schema: "catalog #%" }); -const values = defineQuery>("values")\`SELECT 9007199254740993::bigint AS count, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers\`; +const values = defineQuery>("values")\`SELECT 9007199254740993::bigint AS count, 1.25::numeric AS amount, true AS flag, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers\`; export default defineDatabase({ driver: postgres({ url: ${JSON.stringify(targetUrl)}, shadowUrl: ${JSON.stringify(shadowUrl)}, pool: { max: 1 } }), tables: { entries }, enums: [status], views: [emails], queries: { values } }); `, ); @@ -270,16 +270,20 @@ export default defineDatabase({ driver: postgres({ url: ${JSON.stringify(targetU expect(await fs.readdir(path.join(directory, "migrations"))).toHaveLength(1); const artifact = await fs.readFile(path.join(directory, "generated.ts"), "utf8"); expect(artifact).toContain('readonly "count": string | null;'); + expect(artifact).toContain('readonly "amount": string | null;'); + expect(artifact).toContain('readonly "flag": boolean | null;'); expect(artifact).toContain('readonly "day": Date | null;'); expect(artifact).toContain('readonly "numbers": unknown | null;'); const observer = new Pool({ connectionString: shadowUrl }); try { const [row] = ( await observer.query( - "SELECT 9007199254740993::bigint AS count, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers", + "SELECT 9007199254740993::bigint AS count, 1.25::numeric AS amount, true AS flag, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers", ) ).rows; expect(row.count).toBe("9007199254740993"); + expect(row.amount).toBe("1.25"); + expect(row.flag).toBe(true); expect(row.day).toBeInstanceOf(Date); expect(row.numbers).toEqual([1, 2]); } finally { diff --git a/src/tooling.test.ts b/src/tooling.test.ts index 093eb50..8a912ec 100644 --- a/src/tooling.test.ts +++ b/src/tooling.test.ts @@ -1,7 +1,19 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import ts from "@typescript/typescript6"; import { afterEach, describe, expect, it } from "vitest"; +import { + bigInt, + boolean, + defineDatabase, + defineQuery, + integer, + numeric, + table, + text, +} from "./index"; +import { sqlite } from "./sqlite"; import { diffSnapshots, runDatabaseCli, type SchemaSnapshot } from "./tooling-impl"; const temporaryDirectories: string[] = []; @@ -120,6 +132,106 @@ describe("schema migration generation", () => { }); describe("database CLI", () => { + it("should generate SQLite row types that accept native values from every storage class", async () => { + const packageRoot = path.resolve(import.meta.dirname, ".."); + const root = await fs.mkdtemp(path.join(packageRoot, ".orm-sqlite-types-")); + temporaryDirectories.push(root); + const directory = path.join(root, "database"); + await fs.mkdir(path.join(directory, "migrations"), { recursive: true }); + const schema = "CREATE TABLE typed(n numeric,b bigint,flag boolean,label text,count integer)"; + await fs.writeFile( + path.join(directory, "migrations", "01TYPED.sql"), + `-- askr:id 01TYPED\n-- askr:parent none\n-- askr:transactional true\n\n${schema};\n`, + ); + await fs.writeFile( + path.join(directory, "index.ts"), + `import { bigInt, boolean, defineDatabase, defineQuery, integer, numeric, table, text } from "../../src/index.ts"; +import { sqlite } from "../../src/sqlite.ts"; +const typed = table("typed", { n: numeric(), b: bigInt(), flag: boolean(), label: text(), count: integer() }); +const values = defineQuery>("typed.values")\`SELECT *, n + 1 AS expression FROM typed ORDER BY rowid\`; +export default defineDatabase({ driver: sqlite({ filename: ${JSON.stringify(path.join(root, "target.sqlite"))} }), tables: { typed }, queries: { values } }); +`, + ); + const messages: string[] = []; + const io = { + log: (value: unknown = "") => messages.push(String(value)), + error: (value: unknown = "") => messages.push(String(value)), + }; + for (const command of ["generate", "validate", "generate"]) { + expect(await runDatabaseCli([command], { cwd: root, io }), messages.join("\n")).toBe(0); + } + expect(messages.at(-1)).toBe("default: unchanged"); + + const values = defineQuery>( + "typed.values", + )`SELECT *, n + 1 AS expression FROM typed ORDER BY rowid`; + const driver = sqlite({ filename: ":memory:" }); + const adapter = await driver.open(); + const db = await defineDatabase({ + driver: { ...driver, open: async () => adapter }, + tables: { + typed: table("typed", { + n: numeric(), + b: bigInt(), + flag: boolean(), + label: text(), + count: integer(), + }), + }, + queries: { values }, + }).open(); + try { + await adapter.executeScript!(`${schema}; +INSERT INTO typed VALUES (1.25,42,1,'label',3); +INSERT INTO typed VALUES ('text','text','text',X'0102','text'); +INSERT INTO typed VALUES (X'0102',X'0102',X'0102',NULL,X'0102'); +INSERT INTO typed VALUES (NULL,NULL,NULL,NULL,NULL);`); + const rows = await db.queries.values({}); + const blob = new Uint8Array([1, 2]); + expect(rows).toEqual([ + { n: 1.25, b: 42, flag: 1, label: "label", count: 3, expression: 2.25 }, + { n: "text", b: "text", flag: "text", label: blob, count: "text", expression: 1 }, + { n: blob, b: blob, flag: blob, label: null, count: blob, expression: 1 }, + { n: null, b: null, flag: null, label: null, count: null, expression: null }, + ]); + } finally { + await db.close(); + } + + const consumer = path.join(root, "consumer.ts"); + await fs.writeFile( + consumer, + `import type { GeneratedDatabaseQueries } from "./database/generated"; +type Row = GeneratedDatabaseQueries["typed.values"]["row"]; +const numeric: Row = { n: 1.25, b: 42, flag: 1, label: "label", count: 3, expression: 2.25 }; +const textual: Row = { n: "text", b: "text", flag: "text", label: new Uint8Array([1, 2]), count: "text", expression: 1 }; +const blob: Row = { n: new Uint8Array([1, 2]), b: new Uint8Array([1, 2]), flag: new Uint8Array([1, 2]), label: null, count: new Uint8Array([1, 2]), expression: 1 }; +const empty: Row = { n: null, b: null, flag: null, label: null, count: null, expression: null }; +// @ts-expect-error SQLite does not return JavaScript booleans. +const invalidBoolean: Row["flag"] = true; +// @ts-expect-error The built-in adapter does not enable bigint reads. +const invalidBigint: Row["b"] = 42n; +// @ts-expect-error SQLite does not return Date instances. +const invalidDate: Row["expression"] = new Date(); +void [numeric, textual, blob, empty, invalidBoolean, invalidBigint, invalidDate]; +`, + ); + const program = ts.createProgram([consumer], { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.Preserve, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + noEmit: true, + paths: { "@askrjs/orm": [path.join(packageRoot, "src/index.ts")] }, + }); + expect( + ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")), + ).toEqual([]); + }); + it("should generate, byte-validate, and no-op regenerate a flat project", async () => { const packageRoot = path.resolve(import.meta.dirname, ".."); const root = await fs.mkdtemp(path.join(packageRoot, ".orm-fixture- #% "));