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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions src/tooling-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions src/tooling-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<never, never>>("values")\`SELECT 9007199254740993::bigint AS count, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers\`;
const values = defineQuery<Record<never, never>>("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 } });
`,
);
Expand All @@ -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 {
Expand Down
112 changes: 112 additions & 0 deletions src/tooling.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand Down Expand Up @@ -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<Record<never, never>>("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<Record<never, never>>(
"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- #% "));
Expand Down
Loading