diff --git a/README.md b/README.md index 2239524a..9d937df4 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ ![Typegres playground demo](./assets/demo.gif) -- **Methods on Postgres tables = your API.** No routes. No GraphQL. No auto-CRUD. -- **Every Postgres function, fully typed.** All 77 base types, every operator, nullability tracked at the type level. -- **Clients compose typed SQL across the wire.** Server validates the surface area you expose. +- **Methods on your tables = your API.** No routes. No GraphQL. No auto-CRUD. +- **Every Postgres/SQLite function, fully typed.** All base types, every operator, + nullability tracked at the type level. +- **Clients compose typed SQL across the wire.** Server validates the surface + area you expose. - **Live by default.** `.live()` re-queries when the underlying data changes — pushed directly to clients. > [typegres.com/play](https://typegres.com/play) · [demo.mp4](./assets/demo.mp4) · [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) @@ -15,22 +17,27 @@ > yet recommended for production. ```bash -npm install typegres pg +npm install typegres better-sqlite3 ``` ```typescript -import { typegres, expose } from "typegres"; -import { Int8, Text } from "typegres/postgres"; +import { typegres, expose, sql } from "typegres"; +import { SqliteDriver } from "typegres/drivers/sqlite"; +import { Integer, Text } from "typegres/sqlite"; -const { db, conn } = await typegres({ - type: "pg", - connectionString: process.env.DATABASE_URL!, -}); +const db = typegres(); +const conn = db.connect(SqliteDriver.create()); + +await conn.execute(sql`CREATE TABLE users ( + id INTEGER PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL +)`); class Users extends db.Table("users") { - @expose() id = (Int8<1>).column({ nonNull: true, generated: true }); - @expose() first_name = (Text<1>).column({ nonNull: true }); - @expose() last_name = (Text<1>).column({ nonNull: true }); + @expose() id = Integer.column({ nonNull: true, generated: true }); + @expose() first_name = Text.column({ nonNull: true }); + @expose() last_name = Text.column({ nonNull: true }); // Derived column — composes back into your typed query API. @expose() fullName() { @@ -38,6 +45,11 @@ class Users extends db.Table("users") { } } +await Users.insert( + { first_name: "Alice", last_name: "Smith" }, + { first_name: "Bob", last_name: "Jones" }, +).execute(conn); + // `fullName()` works anywhere a column does — select, where, orderBy: const rows = await Users.from() .select(({ users }) => ({ @@ -50,43 +62,88 @@ console.log(rows); await conn.close(); ``` -For a complete scaffold with migrations + codegen, see -[`examples/basic`](./examples/basic). Or try it interactively at +For a complete scaffold with migrations + codegen, see the +[examples](#examples). Or try it interactively at [typegres.com/play](https://typegres.com/play). +## Backends + +`typegres()` is a synchronous schema handle — no top-level await, so table +classes can be declared at module load. The backend arrives separately via +`db.connect(driver)`, and the same schema classes and query builder run +against any of them: + +```typescript +import { PgDriver } from "typegres/drivers/pg"; // node-postgres +import { PgliteDriver } from "typegres/drivers/pglite"; // in-process WASM Postgres +import { SqliteDriver } from "typegres/drivers/sqlite"; // better-sqlite3 +import { DoSqliteDriver } from "typegres/drivers/do"; // Cloudflare Durable Object + +const db = typegres(); + +db.connect(PgDriver.create(process.env.DATABASE_URL!)); +db.connect(SqliteDriver.create("dev.db")); // omit the filename for :memory: +db.connect(DoSqliteDriver.create(ctx.storage)); // in the DO constructor — no npm peer needed +db.connect(await PgliteDriver.create()); // the one async driver: booting WASM is real I/O +``` + +Drivers are imported explicitly from `typegres/drivers/*` so optional peers +stay out of bundles that never use them — install only the one you need. + +With exactly one connection (the Durable Object model), it's also the +default: `.execute()` / `.live()` take no argument, and you can ignore what +`connect` returns. Pass a `Connection` explicitly when you have several — +read replicas, database-per-tenant, or a transaction's `tx`. + ## How it works -1. **Types codegen'd from the Postgres catalog.** 77 base types, full +1. **Types codegen'd from the Postgres/SQLite catalog/docs.** all base types, full method/operator coverage, nullability tracked at the type level. 2. **Object-capability queries.** Clients can only reach what you've exposed as `@expose` methods — columns, relations, scoped reads, mutations. The class surface is the contract; the schema underneath is free to move. 3. **Object-capability RPC.** The query builder ships to a constrained interpreter on the server; only `@expose`-marked methods reach evaluation. -4. **Live queries.** `.live()` watches the predicates your query depends - on and re-yields when committed mutations would change the result. +4. **Live queries.** Tables opt in with `db.Table("name", { live: true })`. + `.live()` watches the predicates your query depends on and re-yields when + committed mutations would change the result — via a polling bus on + Postgres, and synchronous mutation capture on SQLite. Deeper dive in [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md). +## Examples + +- [`examples/basic`](./examples/basic) — Postgres/PGLite scaffold: + migrations, `tg generate` codegen, relations (`Relation.belongsTo` / `.has`). +- [`examples/sqlite`](./examples/sqlite) — the same scaffold on + better-sqlite3. +- [`examples/chat`](./examples/chat) — full-stack chat on a Cloudflare + Durable Object: SQLite storage, Cap'n Web RPC from the browser, live + queries pushed to clients, and facet-based capability security (the whole + server is the schema — there are no routes). + ## Status - [x] Full pg type system + operator/function codegen +- [x] SQLite dialect — typed function/operator surface from the same + codegen; drivers for better-sqlite3 and Durable Objects - [x] Query builder (`.select` + `.join` + `.where` + `.groupBy` + `.having` + `.orderBy` + `.limit`) - [x] Mutations (`.insert` / `.update` / `.delete` / `.returning`) - [x] Subqueries, scalar/array aggregation -- [x] Table codegen from live schema -- [x] Live queries — `.live()` returns an async iterable that - re-yields when committed mutations would change the result +- [x] Table codegen from live schema (`tg generate`, both dialects) +- [x] Live queries — `.live()` returns a `LiveQuery`: an async iterable you + can also `.observe()` for push delivery (including over RPC) - [x] Capability-rooted RPC — closures composed against `@expose`-marked classes/methods are serialized, evaluated server-side under a - constrained interpreter, and JSON-streamed back + constrained interpreter, and streamed back +- [x] Cap'n Web transport (`typegres/capnweb`) — capabilities, promises, and + live subscriptions over a single WebSocket ## Planned -- [ ] SQLite backend (sql-builder is dialect-aware; adapter is stubbed) -- [ ] `pg_notify`-driven live updates (currently a single shared polling loop, not per-subscription) -- [ ] WAL-mode for live updates (currently uses an auxiliary table) -- [ ] Cap'n Web transport (in-flight upstream PR; +- [ ] `pg_notify`-driven live updates (Postgres currently uses a single shared polling loop, not per-subscription) +- [ ] WAL-mode live updates for Postgres (currently uses an auxiliary table) +- [ ] Upstream the Cap'n Web integration (in-tree shim today; [cloudflare/capnweb#162](https://github.com/cloudflare/capnweb/pull/162)) ## Development diff --git a/examples/basic/src/db.ts b/examples/basic/src/db.ts index ae939e88..52f7beb7 100644 --- a/examples/basic/src/db.ts +++ b/examples/basic/src/db.ts @@ -1,3 +1,8 @@ import { typegres } from "typegres"; +import { PgliteDriver } from "typegres/drivers/pglite"; -export const { db, conn } = await typegres({ type: "pglite" }); +// `typegres()` itself is synchronous; only the driver is awaited, because +// booting WASM Postgres is real I/O. Table classes in ./tables reference +// `db` at module load. +export const db = typegres(); +export const conn = db.connect(await PgliteDriver.create()); diff --git a/examples/chat/worker/api.ts b/examples/chat/worker/api.ts index 5140996e..08640211 100644 --- a/examples/chat/worker/api.ts +++ b/examples/chat/worker/api.ts @@ -13,13 +13,15 @@ // a class inline (X.forY(...)); parameterized ones close over their proof. import { z } from "zod"; -import { Database, expose, Relation } from "typegres"; +import { typegres, expose, Relation } from "typegres"; import { Integer, Text } from "typegres/sqlite"; import { hashPassword, verifyPassword } from "./auth"; -// The Durable Object attaches its ctx.storage to this Database, and that -// single connection is the default for every .execute()/.hydrate()/.live(). -export const db = new Database({ dialect: "sqlite" }); +// Synchronous schema handle — the table classes below are declared against +// it at module load, long before any DO instance exists. The Durable Object +// connects its ctx.storage in its constructor, and that single connection +// is the default for every .execute()/.hydrate()/.live(). +export const db = typegres(); const zUsername = z.string().regex(/^[\w-]{1,24}$/); const zPassword = z.string().min(1).max(128); diff --git a/examples/chat/worker/chat-do.ts b/examples/chat/worker/chat-do.ts index d2604c02..b67f0ce2 100644 --- a/examples/chat/worker/chat-do.ts +++ b/examples/chat/worker/chat-do.ts @@ -8,13 +8,13 @@ import { migrate } from "./migrate"; // One Durable Object holds the whole demo (rooms are rows, not DOs) — // see the README for how this shards to room-per-DO. typegres runs against -// ctx.storage.sql via DoSqliteDriver (same-thread SQLite). +// ctx.storage.sql via the DO SQLite driver (same-thread SQLite). export class ChatDo extends DurableObject { readonly conn: Connection; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - this.conn = db.attach(new DoSqliteDriver(ctx.storage)); + this.conn = db.connect(DoSqliteDriver.create(ctx.storage)); ctx.blockConcurrencyWhile(() => migrate(this.conn)); } diff --git a/examples/sqlite/src/db.ts b/examples/sqlite/src/db.ts index 85eb4a30..0651ec52 100644 --- a/examples/sqlite/src/db.ts +++ b/examples/sqlite/src/db.ts @@ -1,7 +1,10 @@ import { typegres } from "typegres"; +import { SqliteDriver } from "typegres/drivers/sqlite"; -// `typegres({ type: "sqlite" })` opens a SqliteDriver against the given -// file (or `:memory:` if omitted). The tests use `:memory:` so each +// Synchronous end to end — no top-level await: `typegres()` is a +// module-load-safe schema handle, and better-sqlite3 opens the database on +// construction. The tests use `:memory:` (the `sqlite()` default) so each // vitest run is hermetic; the `tg generate` CLI reads schema from the // `./dev.db` file produced by `npm run migrate`. -export const { db, conn } = await typegres({ type: "sqlite" }); +export const db = typegres(); +export const conn = db.connect(SqliteDriver.create()); diff --git a/site/migrate.ts b/site/migrate.ts index 798ddb87..6bb15fd2 100644 --- a/site/migrate.ts +++ b/site/migrate.ts @@ -4,16 +4,17 @@ // source of truth for the schema. import { typegres } from "typegres"; +import { PgDriver } from "typegres/drivers/pg"; import { runMigrations } from "./src/demo/seed.ts"; -const db = await typegres({ - type: "pg", - connectionString: process.env["DATABASE_URL"] ?? "postgres://localhost/postgres", -}); +const db = typegres(); +const conn = db.connect( + PgDriver.create(process.env["DATABASE_URL"] ?? "postgres://localhost/postgres"), +); console.log("Applying migrations..."); -// `db` here is `Database` (no principal type plumbed -// through `typegres({ type: "pg" })`); runMigrations only uses -// .execute, so the cast through unknown is safe. -await runMigrations(db as unknown as Parameters[0]); +// `conn` here is `Connection` (no principal type plumbed +// through a bare `typegres()`); runMigrations only uses .execute, so the +// cast through unknown is safe. +await runMigrations(conn as unknown as Parameters[0]); console.log("Done."); diff --git a/site/src/demo/runtime.ts b/site/src/demo/runtime.ts index 5051b52b..7a4866ea 100644 --- a/site/src/demo/runtime.ts +++ b/site/src/demo/runtime.ts @@ -1,12 +1,15 @@ // Boots PGlite + typegres in the browser, runs migrations + seed. -// Uses top-level await so schema files can import a ready `db` and -// define tables at module-eval time. +// `typegres()` itself is synchronous — the top-level await here is for +// PGlite's WASM boot and the seed, not for the schema handle, so schema +// files can import a ready `db` and define tables at module-eval time. import { ensurePgLiveEventsTable, typegres } from "typegres"; +import { PgliteDriver } from "typegres/drivers/pglite"; import { runMigrations, runSeed } from "./seed"; import type { UserRoot } from "./server/api"; -export const { db, conn } = await typegres({ type: "pglite" }); +export const db = typegres(); +export const conn = db.connect(await PgliteDriver.create()); await runMigrations(conn); await runSeed(conn); diff --git a/src/builder/insert.test.ts b/src/builder/insert.test.ts index 43d4658c..f179ad41 100644 --- a/src/builder/insert.test.ts +++ b/src/builder/insert.test.ts @@ -4,6 +4,7 @@ import * as sqlite from "../types/sqlite"; import type { InsertRow } from "../types/runtime"; import { sql } from "./sql"; import { typegres } from "../index"; +import { SqliteDriver } from "../drivers/sqlite"; import { setupDb, db, withinTransaction } from "../test-helpers"; setupDb(); @@ -147,7 +148,8 @@ test("postgres: column provided in some rows but not others → DEFAULT keyword }); test("sqlite: pruning defers to rowid autoincrement and declared defaults", async () => { - const { db: sdb, conn } = await typegres({ type: "sqlite" }); + const sdb = typegres(); + const conn = sdb.connect(SqliteDriver.create(":memory:")); try { await conn.execute(sql.raw(`CREATE TABLE tagged ( id INTEGER PRIMARY KEY, @@ -173,7 +175,8 @@ test("sqlite: pruning defers to rowid autoincrement and declared defaults", asyn }); test("sqlite: heterogeneous rows raise instead of silently inserting NULL", async () => { - const { db: sdb, conn } = await typegres({ type: "sqlite" }); + const sdb = typegres(); + const conn = sdb.connect(SqliteDriver.create(":memory:")); try { await conn.execute(sql.raw(`CREATE TABLE mixed ( id INTEGER PRIMARY KEY, diff --git a/src/builder/sql.test.ts b/src/builder/sql.test.ts index 135807be..d8194192 100644 --- a/src/builder/sql.test.ts +++ b/src/builder/sql.test.ts @@ -1,12 +1,12 @@ import { test, expect } from "vitest"; import { sql, compile, Ident } from "./sql"; -import { Database } from "../database"; +import { compileOnlyDb } from "../test-helpers"; // Test-only shim: these unit tests exercise SQL emission without a real // Database. Untagged Idents (constructed via the library-internal `new // Ident(name)` path) still pass through — a wrapper for readability. const $ident = (name: string) => new Ident(name); -const pgDb = new Database({ dialect: "postgres" }); -const sqliteDb = new Database({ dialect: "sqlite" }); +const pgDb = compileOnlyDb("postgres"); +const sqliteDb = compileOnlyDb("sqlite"); const pgCtx = { database: pgDb }; const sqliteCtx = { database: sqliteDb }; diff --git a/src/database.test.ts b/src/database.test.ts index cfd354b9..2caeb825 100644 --- a/src/database.test.ts +++ b/src/database.test.ts @@ -15,9 +15,9 @@ let poolDb: Database; let poolConn: Connection; beforeAll(async () => { - poolDriver = await PgDriver.create(requireDatabaseUrl(), { max: 10 }); - poolDb = new Database({ dialect: "postgres" }); - poolConn = poolDb.attach(poolDriver); + poolDriver = PgDriver.create(requireDatabaseUrl(), { max: 10 }); + poolDb = new Database(); + poolConn = poolDb.connect(poolDriver); }); afterAll(async () => { @@ -147,7 +147,7 @@ describe("defaultConnection", () => { }; test("no connection attached → throws", () => { - const empty = new Database({ dialect: "postgres" }); + const empty = new Database(); expect(() => empty.defaultConnection).toThrow(/no connection attached/); }); @@ -170,11 +170,11 @@ describe("defaultConnection", () => { test("ambiguous (two attached) → throws until one closes", async () => { // Fresh db + its own drivers so close() doesn't touch the shared pool. - const fdb = new Database({ dialect: "postgres" }); - const d1 = await PgDriver.create(requireDatabaseUrl(), { max: 1 }); - const d2 = await PgDriver.create(requireDatabaseUrl(), { max: 1 }); - const c1 = fdb.attach(d1); - const c2 = fdb.attach(d2); + const fdb = new Database(); + const d1 = PgDriver.create(requireDatabaseUrl(), { max: 1 }); + const d2 = PgDriver.create(requireDatabaseUrl(), { max: 1 }); + const c1 = fdb.connect(d1); + const c2 = fdb.connect(d2); expect(() => fdb.defaultConnection).toThrow(/2 connections attached/); // Connection.close() deregisters (then closes its driver), restoring an diff --git a/src/database.ts b/src/database.ts index f806aebe..cbefef9b 100644 --- a/src/database.ts +++ b/src/database.ts @@ -36,9 +36,9 @@ const ISOLATION: { [K in TransactionIsolation]: { rank: number; begin: Sql } } = "serializable": { rank: 2, begin: sql`BEGIN ISOLATION LEVEL SERIALIZABLE` }, }; -// Immutable metadata handle: dialect + provenance identity, no driver. -// Construction is synchronous and module-load-safe; call -// `db.attach(driver)` to get a runtime `Connection`. +// Provenance identity, no driver and no dialect of its own. Construction +// is synchronous and module-load-safe; call `db.connect(driver)` to get a +// runtime `Connection`. // // `C` is the per-app context (principal) type, threaded onto every // `db.Table(name)` and readable via `Table.scope(ctx)` / `contextOf(row)`. @@ -47,16 +47,34 @@ const ISOLATION: { [K in TransactionIsolation]: { rank: number; begin: Sql } } = // write it explicitly (see `typegres()` in index.ts). export class Database { readonly name?: string; - readonly dialect: DialectName; // Pool-backed connections currently attached (transaction-bound // Connections are never registered). Basis for `defaultConnection`. readonly #attached: Connection[] = []; + // The first driver ever connected — held for `dialect` alone, and + // deliberately not cleared on close: the schema classes were built + // against that dialect regardless of what is currently open. Held as + // the driver rather than a copied enum so the driver stays the single + // source of truth. + #dialectSource?: Driver; - constructor(opts: { dialect: DialectName; name?: string }) { - this.dialect = opts.dialect; + constructor(opts: { name?: string } = {}) { if (opts.name) { this.name = opts.name; } } + // Passthrough to the first connected driver (`connect` rejects any later + // driver that disagrees, so which one is immaterial). Read before + // anything is connected means queries are being built with no backend — + // the dialect gates SQL rendering and builder-time checks (e.g. insert + // column-presence), so there is no sane default. + get dialect(): DialectName { + if (!this.#dialectSource) { + throw new Error( + "dialect is not known yet — call db.connect(driver) before building queries.", + ); + } + return this.#dialectSource.dialect; + } + // Provenance-tagged identifier factory. The only way to construct a // schema-referencing Ident that survives the compile-time provenance // check. Prefer over raw `sql.ident(name)` (which leaves the Ident @@ -73,19 +91,30 @@ export class Database { public Table = (name: Name, opts: TableOptions = {}) => Table(name, opts, this); - // Attach a driver → get a runtime Connection. Multiple `attach` calls - // are allowed (test + prod, worker pools, replicas) — Connections - // share the schema provenance but talk to independent drivers. + // Connect a driver → get a runtime Connection. Synchronous: every driver + // but PGlite builds without I/O, and that one is awaited by the caller + // (`db.connect(await PgliteDriver.create())`), so the async-ness stays + // with the driver instead of infecting this API. + // + // Multiple `connect` calls are allowed (test + prod, worker pools, read + // replicas, database-per-tenant) — Connections share the schema + // provenance but talk to independent drivers, and must agree on dialect + // since the schema classes compile to one. Single-connection apps can + // ignore the return value and rely on `defaultConnection`. // // The live engine is wired here too: sqlite capture is active from the // start; the pg poller spins up lazily on first .live() use. `liveOpts` // configures the pg bus (poll cadence, backfill window). - attach(driver: Driver, liveOpts?: BusOptions): Connection { - if (driver.dialect !== this.dialect) { + connect(driver: Driver, liveOpts?: BusOptions): Connection { + const source = this.#dialectSource; + if (source && driver.dialect !== source.dialect) { throw new Error( - `Driver dialect '${driver.dialect}' does not match Database dialect '${this.dialect}'.`, + `Driver dialect '${driver.dialect}' does not match the '${source.dialect}' driver already connected.`, ); } + // Set before constructing the Connection — its constructor reads + // `database.dialect` to pick a live executor. + this.#dialectSource ??= driver; const conn = new Connection(this, driver, undefined, undefined, liveOpts); this.#attached.push(conn); return conn; @@ -105,7 +134,7 @@ export class Database { } throw new Error( this.#attached.length === 0 - ? "defaultConnection: no connection attached — call db.attach(driver) first" + ? "defaultConnection: no connection attached — call db.connect(driver) first" : `defaultConnection: ${this.#attached.length} connections attached — pass one explicitly`, ); } @@ -144,7 +173,7 @@ export class Database { } // Runtime handle: has a driver, executes queries. Constructed via -// `db.attach(driver)`. `.transaction()` mints a txn-bound Connection +// `db.connect(driver)`. `.transaction()` mints a txn-bound Connection // sharing the same driver + Database. `.close()` stops the live bus and // closes the driver. export class Connection { diff --git a/src/demo/demo.ts b/src/demo/demo.ts index 196f642e..6ba3be41 100644 --- a/src/demo/demo.ts +++ b/src/demo/demo.ts @@ -1,7 +1,9 @@ import { typegres, sql } from "typegres"; +import { PgliteDriver } from "typegres/drivers/pglite"; import { Int8, Text } from "typegres/postgres"; -const { db, conn } = await typegres({ type: "pglite" }); +const db = typegres(); +const conn = db.connect(await PgliteDriver.create()); // ------------------------------------ // Set up a tiny schema + seed data. diff --git a/src/drivers/do.ts b/src/drivers/do.ts index 951eae54..2614e63a 100644 --- a/src/drivers/do.ts +++ b/src/drivers/do.ts @@ -16,7 +16,7 @@ export interface DoStorageLike { } // typegres Driver over a Durable Object's SQLite storage: -// const conn = db.attach(new DoSqliteDriver(ctx.storage)); +// const conn = db.connect(DoSqliteDriver.create(ctx.storage)); // No node-only peer imports — safe to bundle into workerd. // // Contract notes: @@ -33,7 +33,11 @@ export class DoSqliteDriver implements SyncDriver { return this.#liveSeq; } - constructor(private readonly storage: DoStorageLike) {} + static create(storage: DoStorageLike): DoSqliteDriver { + return new DoSqliteDriver(storage); + } + + private constructor(private readonly storage: DoStorageLike) {} execute: ExecuteFn = (compiled: CompiledSql): Promise => Promise.resolve(this.executeSync(compiled)); diff --git a/src/drivers/pg.ts b/src/drivers/pg.ts index ab0c1c64..d52c12f9 100644 --- a/src/drivers/pg.ts +++ b/src/drivers/pg.ts @@ -1,30 +1,31 @@ import type { CompiledSql } from "../builder/sql"; import type { DialectName } from "../builder/sql"; -import type pg from "pg"; +import pgLib from "pg"; import type { Driver, ExecuteFn, QueryResult } from "./types"; // pg adapter — returns raw text strings (no driver-side deserialization). -// `pg` is an *optional* peer dep (see package.json#peerDependenciesMeta). -// Dynamic import keeps bundlers from pulling pg into browser builds and -// lets a missing peer fail late with a real module-not-found error. +// `pg` is an *optional* peer dep (see package.json#peerDependenciesMeta), +// imported statically because this module only loads when the caller +// imports `typegres/drivers/pg` — bundles that never import this entry +// point never resolve the peer. Pool construction is synchronous; pg +// connects lazily on first query. export class PgDriver implements Driver { readonly dialect: DialectName = "postgres"; - static async create( + static create( connectionString: string, - poolOptions: pg.PoolConfig = {}, - ): Promise { - // eslint-disable-next-line no-restricted-syntax -- optional peer, see class comment - const pgMod = (await import(/* webpackIgnore: true */ "pg")).default; - const pool = new pgMod.Pool({ - connectionString, - ...poolOptions, - types: { getTypeParser: () => (v: string) => v }, - }); - return new PgDriver(pool); + poolOptions: pgLib.PoolConfig = {}, + ): PgDriver { + return new PgDriver( + new pgLib.Pool({ + connectionString, + ...poolOptions, + types: { getTypeParser: () => (v: string) => v }, + }), + ); } - private constructor(private pool: pg.Pool) {} + private constructor(private pool: pgLib.Pool) {} async execute({ text, values }: CompiledSql): Promise { return this.pool.query(text, values as unknown[]); diff --git a/src/drivers/pglite.ts b/src/drivers/pglite.ts index dc30ccaf..3ddce73c 100644 --- a/src/drivers/pglite.ts +++ b/src/drivers/pglite.ts @@ -1,10 +1,13 @@ +import { PGlite } from "@electric-sql/pglite"; import type { CompiledSql } from "../builder/sql"; import type { DialectName } from "../builder/sql"; import type { Driver, ExecuteFn, QueryResult } from "./types"; // pglite adapter — returns raw text strings (no driver-side deserialization). -// `@electric-sql/pglite` is an optional peer. Dynamic import keeps it out of -// bundles that don't need it. +// `@electric-sql/pglite` is an optional peer, imported statically because +// this module only loads when the caller imports `typegres/drivers/pglite`. +// Unlike the other drivers this one is genuinely async: booting the WASM +// engine and reading pg_type to install raw-text parsers both need I/O. type PgliteDb = { query(sql: string, params?: unknown[], opts?: { parsers: { [key: number]: (v: string) => string } }): Promise<{ rows: R[] }>; close(): Promise; @@ -14,8 +17,6 @@ export class PgliteDriver implements Driver { readonly dialect: DialectName = "postgres"; static async create(): Promise { - // eslint-disable-next-line no-restricted-syntax -- optional peer, see class comment - const { PGlite } = await import("@electric-sql/pglite"); const db = new PGlite() as unknown as PgliteDb; // Query all type OIDs so we can override all parsers to return raw strings. const { rows: types } = await db.query<{ oid: number }>("SELECT oid FROM pg_type"); diff --git a/src/drivers/sqlite.ts b/src/drivers/sqlite.ts index d9b0dc92..52e4a206 100644 --- a/src/drivers/sqlite.ts +++ b/src/drivers/sqlite.ts @@ -1,12 +1,14 @@ import type { CompiledSql } from "../builder/sql"; import type { DialectName } from "../builder/sql"; -import type BetterSqlite3 from "better-sqlite3"; +import BetterSqlite3 from "better-sqlite3"; import type { ExecuteSyncFn, QueryResult, SyncDriver } from "./types"; import { normalizeRow, stripMatchedOuterParens } from "./shared-sqlite"; // better-sqlite3 adapter. Synchronous under the hood; wrapped in // Promise.resolve for the async Driver contract. `better-sqlite3` is an -// optional peer (see package.json). +// optional peer (see package.json) — imported statically because this +// module only loads when the caller imports `typegres/drivers/sqlite`, +// so bundles that never touch SQLite never resolve the peer. export class SqliteDriver implements SyncDriver { readonly dialect: DialectName = "sqlite"; @@ -15,14 +17,11 @@ export class SqliteDriver implements SyncDriver { return this.#liveSeq; } - static async create( + static create( filename: string = ":memory:", options: BetterSqlite3.Options = {}, - ): Promise { - // eslint-disable-next-line no-restricted-syntax -- optional peer, matches PgDriver/PgliteDriver pattern - const mod = (await import("better-sqlite3")).default; - const db = new mod(filename, options); - return new SqliteDriver(db); + ): SqliteDriver { + return new SqliteDriver(new BetterSqlite3(filename, options)); } private constructor(private db: BetterSqlite3.Database) {} diff --git a/src/hydrate.test.ts b/src/hydrate.test.ts index 20bf7628..2b29e9bf 100644 --- a/src/hydrate.test.ts +++ b/src/hydrate.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, expectTypeOf, beforeAll } from "vitest"; import { typegres, sql, expose } from "typegres"; +import { PgliteDriver } from "typegres/drivers/pglite"; import { Int8, Text, Bool } from "typegres/postgres"; import type { Connection, Database, QueryBuilder } from "typegres"; @@ -8,7 +9,8 @@ import type { Connection, Database, QueryBuilder } from "typegres"; // Constructed at module load via typegres(); classes below reference // db.Table so their Idents carry provenance. -const { db, conn } = await typegres({ type: "pglite" }); +const db = typegres(); +const conn = db.connect(await PgliteDriver.create()); // Placeholder type usage to keep the imports referenced. const _typed: [Database, Connection] = [db, conn]; diff --git a/src/index.ts b/src/index.ts index 8b5a6651..6a383532 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,39 +22,27 @@ export type { RawChannel } from "./exoeval/rpc"; export type { Config } from "./config"; export type { Driver, SyncDriver, ExecuteFn, ExecuteSyncFn, QueryResult } from "./drivers/types"; -import type { Connection } from "./database"; import { Database } from "./database"; -import type { Driver } from "./drivers/types"; -import type { DialectName } from "./builder/sql"; -// Convenience factory for scripts / playground. Drivers are loaded via -// dynamic import so a static `import { Database } from "typegres"` does not -// pull optional peers into Worker bundles that never call typegres(). -export const typegres = async ( - opts: - | { type: "pglite" } - | { type: "pg"; connectionString: string } - | { type: "sqlite"; filename?: string }, -): Promise<{ db: Database; conn: Connection }> => { - let driver: Driver; - let dialect: DialectName; - if (opts.type === "pglite") { - // eslint-disable-next-line no-restricted-syntax -- optional peer path - const { PgliteDriver } = await import("./drivers/pglite"); - driver = await PgliteDriver.create(); - dialect = "postgres"; - } else if (opts.type === "pg") { - // eslint-disable-next-line no-restricted-syntax -- optional peer path - const { PgDriver } = await import("./drivers/pg"); - driver = await PgDriver.create(opts.connectionString); - dialect = "postgres"; - } else { - // eslint-disable-next-line no-restricted-syntax -- optional peer path - const { SqliteDriver } = await import("./drivers/sqlite"); - driver = await SqliteDriver.create(opts.filename ?? ":memory:"); - dialect = "sqlite"; - } - const db = new Database({ dialect }); - const conn = db.attach(driver); - return { db, conn }; -}; +/** + * The entry point: a synchronous, module-load-safe schema handle. + * + * import { typegres } from "typegres"; + * import { SqliteDriver } from "typegres/drivers/sqlite"; + * + * const db = typegres(); + * db.connect(SqliteDriver.create("dev.db")); + * + * class Users extends db.Table("users") { … } + * + * Synchronous by design: table classes are declared at module scope + * against `db`, so making this async would force top-level await through + * every module that defines a table. Backends arrive later via + * `db.connect(driver)`, with the driver imported from `typegres/drivers/*` + * — an explicit import keeps optional peers out of bundles that don't use + * them, and keeps `connect` synchronous for every driver but PGlite. + * + * No arguments: the driver is the source of truth for the dialect, so the + * backend is named exactly once, where the driver is built. + */ +export const typegres = (): Database => new Database(); diff --git a/src/live/exoeval-live.test.ts b/src/live/exoeval-live.test.ts index e15052b8..635f501f 100644 --- a/src/live/exoeval-live.test.ts +++ b/src/live/exoeval-live.test.ts @@ -21,7 +21,7 @@ import { Integer, Text } from "../types/sqlite"; import { expose } from "../exoeval/tool"; import { RpcClient, inMemoryChannel } from "../exoeval/rpc"; -const db = new Database({ dialect: "sqlite" }); +const db = new Database(); class Notes extends db.Table("notes", { live: true }) { @expose() id = Integer.column({ nonNull: true }); @@ -41,7 +41,7 @@ class Api { let conn: Connection; beforeAll(async () => { - conn = db.attach(await SqliteDriver.create(":memory:")); + conn = db.connect(SqliteDriver.create(":memory:")); await conn.execute( sql`CREATE TABLE notes (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, body TEXT NOT NULL)`, ); diff --git a/src/live/extractor.test.ts b/src/live/extractor.test.ts index b5fabc16..b49b0492 100644 --- a/src/live/extractor.test.ts +++ b/src/live/extractor.test.ts @@ -1,12 +1,12 @@ import { test, expect } from "vitest"; import { Int8, Text } from "../types/postgres"; -import { Database } from "../database"; +import { compileOnlyDb } from "../test-helpers"; import { sql } from "../builder/sql"; import { expectSqlEqual } from "../test-helpers"; // Local metadata Database — extractor tests don't execute queries, just // walk the tree, so no driver / conn needed. -const db = new Database({ dialect: "postgres" }); +const db = compileOnlyDb("postgres"); import { buildExtractor, materializePredicateSet, sortAliases, traverse } from "./extractor"; class Users extends db.Table("users") { diff --git a/src/live/sqlite/db-live.test.ts b/src/live/sqlite/db-live.test.ts index 220cef53..7593b972 100644 --- a/src/live/sqlite/db-live.test.ts +++ b/src/live/sqlite/db-live.test.ts @@ -11,14 +11,14 @@ import { Integer, Real, Text } from "../../types/sqlite"; // synchronous with the mutation, so every re-yield below resolves without // timers. The same backend drives DoSqliteDriver (Durable Objects). -const db = new Database({ dialect: "sqlite" }); +const db = new Database(); let driver: SqliteDriver; let conn: Connection; beforeAll(async () => { - driver = await SqliteDriver.create(":memory:"); - conn = db.attach(driver); + driver = SqliteDriver.create(":memory:"); + conn = db.connect(driver); }); afterAll(async () => { @@ -314,8 +314,8 @@ test("cancelLiveSubscriptions releases a parked consumer cleanly", async () => { }, 10_000); test("close() stops the live engine and releases a parked consumer", async () => { - const ownDriver = await SqliteDriver.create(":memory:"); - const ownConn = db.attach(ownDriver); + const ownDriver = SqliteDriver.create(":memory:"); + const ownConn = db.connect(ownDriver); await ownConn.execute(sql`CREATE TABLE notes ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, @@ -370,7 +370,7 @@ test("live over DoSqliteDriver (fake SqlStorage backed by better-sqlite3)", asyn } }, }; - const doConn = db.attach(new DoSqliteDriver(storage)); + const doConn = db.connect(DoSqliteDriver.create(storage)); await doConn.execute(sql`CREATE TABLE notes ( id INTEGER PRIMARY KEY, diff --git a/src/live/sqlite/live.do-test.ts b/src/live/sqlite/live.do-test.ts index 8f22bc76..a3b2b521 100644 --- a/src/live/sqlite/live.do-test.ts +++ b/src/live/sqlite/live.do-test.ts @@ -10,7 +10,7 @@ import { DoSqliteDriver } from "../../drivers/do"; import { sql } from "../../builder/sql"; import { Integer, Text } from "../../types/sqlite"; -const db = new Database({ dialect: "sqlite" }); +const db = new Database(); class Notes extends db.Table("notes", { live: true }) { id = Integer.column({ nonNull: true }); @@ -27,7 +27,7 @@ const takeNext = async (iter: AsyncIterator): Promise => { test("live insert/update/delete round-trip on real DO SqlStorage", async () => { const stub = env.TEST_DO.getByName("live-round-trip"); await runInDurableObject(stub, async (_instance, state) => { - const conn = db.attach(new DoSqliteDriver(state.storage)); + const conn = db.connect(DoSqliteDriver.create(state.storage)); await conn.execute(sql`CREATE TABLE notes ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, @@ -64,7 +64,7 @@ test("live insert/update/delete round-trip on real DO SqlStorage", async () => { test("transaction: buffered events flush at COMMIT on real DO", async () => { const stub = env.TEST_DO.getByName("live-tx"); await runInDurableObject(stub, async (_instance, state) => { - const conn = db.attach(new DoSqliteDriver(state.storage)); + const conn = db.connect(DoSqliteDriver.create(state.storage)); await conn.execute(sql`CREATE TABLE notes ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, diff --git a/src/provenance.test.ts b/src/provenance.test.ts index 7470b6b7..827592e1 100644 --- a/src/provenance.test.ts +++ b/src/provenance.test.ts @@ -8,17 +8,17 @@ // This suite is unit-only (no driver / no execute); everything is // exercised via `compile(sql, { database })` directly. import { test, expect, describe } from "vitest"; -import { Database } from "./database"; +import { compileOnlyDb } from "./test-helpers"; import { compile, sql, Ident, UnaryOp, Raw, Param } from "./builder/sql"; import { Int4, Text } from "./types/postgres"; import { Integer } from "./types/sqlite"; // Two same-dialect databases → distinct provenance identities. -const dbA = new Database({ dialect: "postgres", name: "dbA" }); -const dbB = new Database({ dialect: "postgres", name: "dbB" }); +const dbA = compileOnlyDb("postgres", "dbA"); +const dbB = compileOnlyDb("postgres", "dbB"); // Different-dialect databases for Func/Op/Cast/Srf dialect checks. -const pgDb = new Database({ dialect: "postgres", name: "pg" }); -const sqliteDb = new Database({ dialect: "sqlite", name: "sqlite" }); +const pgDb = compileOnlyDb("postgres", "pg"); +const sqliteDb = compileOnlyDb("sqlite", "sqlite"); describe("Ident provenance", () => { test("Ident from db A rejected when compiled against db B", () => { diff --git a/src/readme.test.ts b/src/readme.test.ts index 830e28b9..33621267 100644 --- a/src/readme.test.ts +++ b/src/readme.test.ts @@ -4,6 +4,11 @@ // init signature, decorator semantics) fails this test in CI before the // README ever gets to a reader. // +// The snippet is fully self-contained: it spins up an in-memory SQLite +// database, creates its own table, inserts, and queries — so this test +// needs no database fixture at all. That's the point of the snippet: +// `npm install`, paste, run. +// // Two install modes: // - working-tree (default): `npm install file:`, which packs the // local repo internally and honors the package.json `files` @@ -26,22 +31,17 @@ import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import * as swc from "@swc/core"; -import { sql } from "./builder/sql"; -import { setupDb, conn } from "./test-helpers"; -import { requireDatabaseUrl } from "./pg"; const execFileP = promisify(execFile); const REPO_ROOT = path.resolve(import.meta.dirname, ".."); const README_PATH = path.join(REPO_ROOT, "README.md"); -setupDb(); - type InstallMode = "working-tree" | "registry"; const runReadmeUsage = async (mode: InstallMode): Promise => { const readme = fs.readFileSync(README_PATH, "utf8"); // Scope to the Usage section so we don't pick up code blocks from - // other sections (Development, Status, etc.). + // other sections (Backends, Development, etc.). const usageSection = /## Usage[\s\S]*?(?=\n## |$)/.exec(readme)?.[0] ?? ""; const bashSnippet = /```bash\n([\s\S]*?)```/.exec(usageSection)?.[1]?.trim(); const tsSnippet = /```typescript\n([\s\S]*?)```/.exec(usageSection)?.[1]; @@ -58,76 +58,58 @@ const runReadmeUsage = async (mode: InstallMode): Promise => { } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `typegres-readme-${mode}-`)); - try { - fs.writeFileSync( - path.join(tmpDir, "package.json"), - JSON.stringify({ name: "readme-test", type: "module", private: true }), - ); - // working-tree: file: reference to the repo. npm packs the local - // directory using the `files` manifest internally — same effect as - // `npm pack && npm install `, no tarball management. - // registry: install verbatim from npm. - // - // Flags shave ~3-5s off install: skip the security audit (we're a - // disposable tmp dir), skip funding messages, prefer the offline - // cache before hitting the registry. - const installCmd = ( - mode === "working-tree" - ? bashSnippet.replace(/\btypegres\b/, JSON.stringify(`file:${REPO_ROOT}`)) - : bashSnippet - ).replace(/\bnpm install\b/, `npm install --no-audit --no-fund ${mode === 'working-tree' ? '--prefer-offline' : ''}`); - await execFileP("sh", ["-c", installCmd], { cwd: tmpDir }); + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "readme-test", type: "module", private: true }), + ); - // Compile the snippet via swc — handles stage-3 decorators that - // node's strip-types alone can't transform. - const compiled = await swc.transform(tsSnippet, { - filename: "main.ts", - jsc: { - target: "es2022", - parser: { syntax: "typescript", decorators: true }, - transform: { decoratorVersion: "2022-03" }, - }, - module: { type: "es6" }, - isModule: true, - }); - fs.writeFileSync(path.join(tmpDir, "main.mjs"), compiled.code); + // working-tree: file: reference to the repo. npm packs the local + // directory using the `files` manifest internally — same effect as + // `npm pack && npm install `, no tarball management. + // registry: install verbatim from npm. + // + // Flags shave ~3-5s off install: skip the security audit (we're a + // disposable tmp dir), skip funding messages, prefer the offline + // cache before hitting the registry. + const installCmd = ( + mode === "working-tree" + ? bashSnippet.replace(/\btypegres\b/, JSON.stringify(`file:${REPO_ROOT}`)) + : bashSnippet + ).replace( + /\bnpm install\b/, + `npm install --no-audit --no-fund ${mode === "working-tree" ? "--prefer-offline" : ""}`, + ); + await execFileP("sh", ["-c", installCmd], { cwd: tmpDir }); - // Seed the per-worker schema with what the snippet expects. - await conn.execute(sql`CREATE TABLE users ( - id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - first_name text NOT NULL, - last_name text NOT NULL - )`); - await conn.execute(sql`INSERT INTO users (first_name, last_name) VALUES - ('Alice', 'Smith'), - ('Bob', 'Jones')`); + // Compile the snippet via swc — handles stage-3 decorators that + // node's strip-types alone can't transform. + const compiled = await swc.transform(tsSnippet, { + filename: "main.ts", + jsc: { + target: "es2022", + parser: { syntax: "typescript", decorators: true }, + transform: { decoratorVersion: "2022-03" }, + }, + module: { type: "es6" }, + isModule: true, + }); + fs.writeFileSync(path.join(tmpDir, "main.mjs"), compiled.code); - // Same DB; PGOPTIONS pins search_path to the worker schema so bare - // `users` resolves into the test's namespace. - const schema = `test_w${process.env["VITEST_WORKER_ID"] ?? "1"}`; - const { stdout } = await execFileP("node", ["main.mjs"], { - cwd: tmpDir, - env: { - ...process.env, - DATABASE_URL: requireDatabaseUrl(), - PGOPTIONS: `-csearch_path=${schema}`, - }, - }); + const { stdout } = await execFileP("node", ["main.mjs"], { cwd: tmpDir }); - expect(stdout).toContain("Alice Smith"); - expect(stdout).toContain("Bob Jones"); - } finally { - await conn.execute(sql`DROP TABLE IF EXISTS users`).catch(() => {}); - } - // Only delete the temp dir if everything succeeded: + expect(stdout).toContain("Alice Smith"); + expect(stdout).toContain("Bob Jones"); + + // Only delete the temp dir if everything succeeded — leave it behind + // for debugging on failure. fs.rmSync(tmpDir, { recursive: true, force: true }); }; test( "README.md Usage snippet — working tree (file:)", () => runReadmeUsage("working-tree"), - 30_000, // typical: ~2s; generous for slow npm cache misses. + 60_000, // typical: ~5s; generous for better-sqlite3 prebuilt download on cache misses. ); // Registry mode: opt-in via env var. Tests the currently-published diff --git a/src/test-helpers.ts b/src/test-helpers.ts index e29abe5d..063b3833 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -1,16 +1,46 @@ import { beforeAll, afterAll, expect } from "vitest"; import { PgDriver } from "./drivers/pg"; -import type { Driver } from "./drivers/types"; +import type { Driver, SyncDriver } from "./drivers/types"; import { requireDatabaseUrl } from "./pg"; import { Database } from "./database"; import { compile, sql } from "./builder/sql"; import type { Sql } from "./builder/sql"; import type { TransactionIsolation , Connection } from "./database"; +import type { DialectName } from "./builder/sql"; export let driver: Driver; export let db: Database; export let conn: Connection; +// The driver owns the dialect, so a Database learns it by connecting one. +// Unit suites that only compile SQL (provenance, extractor, type-level +// match tests) have no real backend, so they connect this instead: it +// carries a dialect and nothing else — every execute path throws. +// +// Implements SyncDriver, not just Driver: the sqlite live executor rejects +// async drivers at Connection construction, so a compile-only sqlite +// handle has to look synchronous even though it never runs anything. +export const dialectOnlyDriver = (dialect: DialectName): SyncDriver => { + const unsupported = (): never => { + throw new Error(`dialectOnlyDriver('${dialect}'): compile-only, cannot execute`); + }; + return { + dialect, + liveSeq: 0n, + execute: unsupported, + executeSync: unsupported, + runInSingleConnection: unsupported, + close: () => Promise.resolve(), + }; +}; + +// Sugar for the same: a compile-only Database of the given dialect. +export const compileOnlyDb = (dialect: DialectName, name?: string): Database => { + const d = new Database(name === undefined ? {} : { name }); + d.connect(dialectOnlyDriver(dialect)); + return d; +}; + // Per-worker schema isolates tables so test files can run in parallel against // one Postgres. search_path is set at connection startup (via libpq options), // so bare table names (`dogs`, `_live_events`, ...) resolve inside the @@ -22,15 +52,15 @@ const schema = `test_w${process.env["VITEST_WORKER_ID"] ?? "1"}`; // for that file's suite. Unit-only test files don't call it and avoid // booting Postgres. export const setupDb = (): void => { - db = new Database({ dialect: "postgres" }); + db = new Database(); beforeAll(async () => { - driver = await PgDriver.create(requireDatabaseUrl(), { + driver = PgDriver.create(requireDatabaseUrl(), { max: 1, options: `-csearch_path=${schema}`, }); // Fast poll cadence for live suites; harmless elsewhere — the pg // poller only starts on first .live() use. - conn = db.attach(driver, { intervalMs: 25 }); + conn = db.connect(driver, { intervalMs: 25 }); await conn.execute(sql`DROP SCHEMA IF EXISTS ${db.scopedIdent(schema)} CASCADE`); await conn.execute(sql`CREATE SCHEMA ${db.scopedIdent(schema)}`); }); diff --git a/src/types/match.test.ts b/src/types/match.test.ts index 2aad6638..a7cc0e55 100644 --- a/src/types/match.test.ts +++ b/src/types/match.test.ts @@ -1,9 +1,9 @@ import { test, expect } from "vitest"; import { Int4, Int8, Text, Bool } from "./postgres"; import { compile } from "../builder/sql"; -import { Database } from "../database"; +import { compileOnlyDb } from "../test-helpers"; -const pgCtx = { database: new Database({ dialect: "postgres" }) }; +const pgCtx = { database: compileOnlyDb("postgres") }; // --- match via operators/functions --- diff --git a/src/types/postgres/index.test.ts b/src/types/postgres/index.test.ts index 4f1ac06c..5923b6b9 100644 --- a/src/types/postgres/index.test.ts +++ b/src/types/postgres/index.test.ts @@ -9,15 +9,16 @@ import { PgDriver } from "../../drivers/pg"; import { requireDatabaseUrl } from "../../pg"; import type { Connection } from "../../database"; import { Database } from "../../database"; +import { compileOnlyDb } from "../../test-helpers"; -const pgCtx = { database: new Database({ dialect: "postgres" }) }; +const pgCtx = { database: compileOnlyDb("postgres") }; let exec: Connection; beforeAll(async () => { - const driver = await PgDriver.create(requireDatabaseUrl(), { max: 1 }); - const localDb = new Database({ dialect: "postgres" }); - exec = localDb.attach(driver); + const driver = PgDriver.create(requireDatabaseUrl(), { max: 1 }); + const localDb = new Database(); + exec = localDb.connect(driver); }); afterAll(async () => { diff --git a/src/types/sqlite/signatures.verify.test.ts b/src/types/sqlite/signatures.verify.test.ts index 76edea5e..e6ec365a 100644 --- a/src/types/sqlite/signatures.verify.test.ts +++ b/src/types/sqlite/signatures.verify.test.ts @@ -28,7 +28,7 @@ import camelcase from "camelcase"; import { SIGNATURES, EXCLUSIONS } from "./emit.ts"; import type { EmitFn as FnDef, EmitOverload as Overload, EmitArg as ArgDef } from "../emission/facts.ts"; import { UNARY_OPERATOR_ALIASES } from "../emission/common.ts"; -import { Database as TypegresDatabase } from "../../database"; +import { compileOnlyDb } from "../../test-helpers"; import { compile, sql } from "../../builder/sql"; import * as types from "./index"; @@ -47,7 +47,7 @@ const prep = (text: string): Database.Statement => { // Compile context for the typed-surface side (no driver needed — the // compiled text/values run on the raw better-sqlite3 handle above). -const tg = new TypegresDatabase({ dialect: "sqlite" }); +const tg = compileOnlyDb("sqlite"); // Stable per-function seed so CI runs are reproducible; on failure // fast-check prints the seed + shrunk counterexample. diff --git a/src/types/sqlite/smoke.test.ts b/src/types/sqlite/smoke.test.ts index 4ff3d34c..56fbcc20 100644 --- a/src/types/sqlite/smoke.test.ts +++ b/src/types/sqlite/smoke.test.ts @@ -20,9 +20,9 @@ let db: Database; let conn: Connection; beforeAll(async () => { - driver = await SqliteDriver.create(":memory:"); - db = new Database({ dialect: "sqlite" }); - conn = db.attach(driver); + driver = SqliteDriver.create(":memory:"); + db = new Database(); + conn = db.connect(driver); }); afterAll(async () => { diff --git a/src/types/sqlite/table.test.ts b/src/types/sqlite/table.test.ts index 3048afb3..4d2cc41b 100644 --- a/src/types/sqlite/table.test.ts +++ b/src/types/sqlite/table.test.ts @@ -14,7 +14,7 @@ import { sql } from "../../builder/sql"; import { expose } from "../../exoeval/tool"; import { Integer, Text } from "./index"; -const db = new Database({ dialect: "sqlite" }); +const db = new Database(); class Users extends db.Table("users") { @expose() @@ -39,8 +39,8 @@ const withinTransaction = async (fn: (tx: Connection) => Promise) => { }; beforeAll(async () => { - driver = await SqliteDriver.create(":memory:"); - conn = db.attach(driver); + driver = SqliteDriver.create(":memory:"); + conn = db.connect(driver); await conn.execute(sql`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`); }); diff --git a/tsconfig.json b/tsconfig.json index f95f2363..166b0975 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,11 @@ "paths": { "typegres": ["./src/index.ts"], "typegres/postgres": ["./src/types/postgres/index.ts"], - "typegres/sqlite": ["./src/types/sqlite/index.ts"] + "typegres/sqlite": ["./src/types/sqlite/index.ts"], + "typegres/drivers/pg": ["./src/drivers/pg.ts"], + "typegres/drivers/pglite": ["./src/drivers/pglite.ts"], + "typegres/drivers/sqlite": ["./src/drivers/sqlite.ts"], + "typegres/drivers/do": ["./src/drivers/do.ts"] } }, "include": ["src"] diff --git a/vitest.config.ts b/vitest.config.ts index fdeb4b40..5063eda0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,10 @@ const shared = { // Order matters — Vite matches aliases longest-first, so subpath // entries come before the bare `typegres` root. alias: { + "typegres/drivers/pglite": `${src}/drivers/pglite.ts`, + "typegres/drivers/sqlite": `${src}/drivers/sqlite.ts`, + "typegres/drivers/pg": `${src}/drivers/pg.ts`, + "typegres/drivers/do": `${src}/drivers/do.ts`, "typegres/postgres": `${src}/types/postgres/index.ts`, "typegres/sqlite": `${src}/types/sqlite/index.ts`, typegres: `${src}/index.ts`,