From 53817958442dcb53040449b42ebeb8c148e5339a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 20 May 2026 20:09:07 -0400 Subject: [PATCH] feat(effect-drizzle-sqlite): add vendored sqlite adapter (#28547) --- bun.lock | 19 + package.json | 1 + packages/effect-drizzle-sqlite/AGENTS.md | 19 + .../effect-drizzle-sqlite/examples/basic.ts | 92 ++++ .../20240101000000_create_users/migration.sql | 4 + packages/effect-drizzle-sqlite/package.json | 29 ++ .../src/effect-sqlite/driver.ts | 77 +++ .../src/effect-sqlite/index.ts | 4 + .../src/effect-sqlite/migrator.ts | 14 + .../src/effect-sqlite/session.ts | 214 ++++++++ packages/effect-drizzle-sqlite/src/index.ts | 6 + .../src/internal/drizzle-utils.ts | 127 +++++ .../src/sqlite-core/effect/count.ts | 58 +++ .../src/sqlite-core/effect/db.ts | 296 +++++++++++ .../src/sqlite-core/effect/delete.ts | 261 ++++++++++ .../src/sqlite-core/effect/index.ts | 10 + .../src/sqlite-core/effect/insert.ts | 349 +++++++++++++ .../src/sqlite-core/effect/query.ts | 198 +++++++ .../src/sqlite-core/effect/raw.ts | 49 ++ .../src/sqlite-core/effect/select.ts | 279 ++++++++++ .../src/sqlite-core/effect/session.ts | 490 ++++++++++++++++++ .../src/sqlite-core/effect/update.ts | 402 ++++++++++++++ .../src/up-migrations/effect-sqlite.ts | 102 ++++ .../src/up-migrations/sqlite.ts | 253 +++++++++ .../src/up-migrations/utils.ts | 45 ++ .../effect-drizzle-sqlite/test/sqlite.test.ts | 139 +++++ packages/effect-drizzle-sqlite/tsconfig.json | 15 + specs/storage/effect-sqlite-package.md | 145 ++++++ 28 files changed, 3697 insertions(+) create mode 100644 packages/effect-drizzle-sqlite/AGENTS.md create mode 100644 packages/effect-drizzle-sqlite/examples/basic.ts create mode 100644 packages/effect-drizzle-sqlite/examples/migrations/20240101000000_create_users/migration.sql create mode 100644 packages/effect-drizzle-sqlite/package.json create mode 100644 packages/effect-drizzle-sqlite/src/effect-sqlite/driver.ts create mode 100644 packages/effect-drizzle-sqlite/src/effect-sqlite/index.ts create mode 100644 packages/effect-drizzle-sqlite/src/effect-sqlite/migrator.ts create mode 100644 packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts create mode 100644 packages/effect-drizzle-sqlite/src/index.ts create mode 100644 packages/effect-drizzle-sqlite/src/internal/drizzle-utils.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/count.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/db.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/delete.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/index.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/insert.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/query.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/raw.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/select.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts create mode 100644 packages/effect-drizzle-sqlite/src/sqlite-core/effect/update.ts create mode 100644 packages/effect-drizzle-sqlite/src/up-migrations/effect-sqlite.ts create mode 100644 packages/effect-drizzle-sqlite/src/up-migrations/sqlite.ts create mode 100644 packages/effect-drizzle-sqlite/src/up-migrations/utils.ts create mode 100644 packages/effect-drizzle-sqlite/test/sqlite.test.ts create mode 100644 packages/effect-drizzle-sqlite/tsconfig.json create mode 100644 specs/storage/effect-sqlite-package.md diff --git a/bun.lock b/bun.lock index 847484aa63..037b72a29b 100644 --- a/bun.lock +++ b/bun.lock @@ -305,6 +305,20 @@ "@parcel/watcher-win32-x64": "2.5.1", }, }, + "packages/effect-drizzle-sqlite": { + "name": "@opencode-ai/effect-drizzle-sqlite", + "version": "1.15.5", + "dependencies": { + "drizzle-orm": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/sql-sqlite-bun": "catalog:", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/enterprise": { "name": "@opencode-ai/enterprise", "version": "1.15.6", @@ -717,6 +731,7 @@ "@cloudflare/workers-types": "4.20251008.0", "@effect/opentelemetry": "4.0.0-beta.66", "@effect/platform-node": "4.0.0-beta.66", + "@effect/sql-sqlite-bun": "4.0.0-beta.66", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@lydell/node-pty": "1.2.0-beta.10", @@ -1114,6 +1129,8 @@ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.66", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.66", "", { "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-UYsrAb/5T0ZRypeN9Kmv3/ZInibGCjM6dtoiAWtfG+xKyuq8N05wmuVCXB0+XgVmUBxDWjw/S1fu4ivS0vZVuw=="], + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], @@ -1542,6 +1559,8 @@ "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], + "@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"], + "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], diff --git a/package.json b/package.json index 72ce3175b2..b48dedad89 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "catalog": { "@effect/opentelemetry": "4.0.0-beta.66", "@effect/platform-node": "4.0.0-beta.66", + "@effect/sql-sqlite-bun": "4.0.0-beta.66", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.13", "@types/cross-spawn": "6.0.6", diff --git a/packages/effect-drizzle-sqlite/AGENTS.md b/packages/effect-drizzle-sqlite/AGENTS.md new file mode 100644 index 0000000000..39010a1127 --- /dev/null +++ b/packages/effect-drizzle-sqlite/AGENTS.md @@ -0,0 +1,19 @@ +# Effect Drizzle SQLite + +This package vendors a Drizzle Effect SQLite adapter for this repo. + +- Keep this package generic: Drizzle + Effect + SQLite only. +- Do not add opencode-specific tables, paths, migrations, post-commit hooks, or domain storage APIs here. +- Runtime code should depend on generic `effect/unstable/sql/SqlClient`, not a specific SQLite driver. +- Concrete SQLite clients such as `@effect/sql-sqlite-bun` belong in tests or examples unless this package intentionally adds a driver-specific helper. +- Preserve Drizzle adapter naming and behavior where possible so this can be replaced by upstream `drizzle-orm/effect-sqlite` later. +- If touching copied Drizzle internals, compare with current `drizzle-orm@1.0.0-rc.2` declarations and runtime JS. +- If touching Effect APIs, verify against `/Users/kit/code/open-source/effect-smol`. + +Useful entry points: + +- `src/effect-sqlite/driver.ts`: creates the Effect-backed Drizzle database with `make` and `makeWithDefaults`. +- `src/effect-sqlite/session.ts`: adapts generic Effect `SqlClient` execution and transactions to Drizzle SQLite sessions. +- `src/sqlite-core/effect/*`: Effect-yieldable SQLite query builders. +- `src/internal/drizzle-utils.ts`: local typed shims for Drizzle runtime internals that RC2 does not expose in declarations. +- `examples/basic.ts`: minimal usage example with Bun SQLite. diff --git a/packages/effect-drizzle-sqlite/examples/basic.ts b/packages/effect-drizzle-sqlite/examples/basic.ts new file mode 100644 index 0000000000..675aabcb85 --- /dev/null +++ b/packages/effect-drizzle-sqlite/examples/basic.ts @@ -0,0 +1,92 @@ +import { SqliteClient } from "@effect/sql-sqlite-bun" +import { eq } from "drizzle-orm" +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Schema from "effect/Schema" +import { EffectDrizzleSqlite } from "../src" + +const users = sqliteTable("users", { + id: integer().primaryKey({ autoIncrement: true }), + name: text().notNull(), +}) + +type User = typeof users.$inferSelect + +const makeDatabase = EffectDrizzleSqlite.makeWithDefaults() +type DatabaseShape = Effect.Success + +const sqliteLayer = SqliteClient.layer({ filename: ":memory:", disableWAL: true }) + +class Database extends Context.Service()("@opencode/example/Database") { + static layer = Layer.effect(Database, makeDatabase).pipe(Layer.provide(sqliteLayer)) +} + +class UserStoreError extends Schema.TaggedErrorClass()("UserStoreError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause }) + +interface UserStoreShape { + migrate(): Effect.Effect + create(name: string): Effect.Effect + rename(from: string, to: string): Effect.Effect + list(): Effect.Effect +} + +class UserStore extends Context.Service()("@opencode/example/UserStore") { + static layer = Layer.effect( + UserStore, + Effect.gen(function* () { + const db = yield* Database + + return UserStore.of({ + migrate: Effect.fn("UserStore.migrate")(function* () { + yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder: `${import.meta.dirname}/migrations` }).pipe( + Effect.mapError((cause) => new UserStoreError({ message: "Failed to migrate users", cause })), + ) + }), + create: Effect.fn("UserStore.create")(function* (name: string) { + yield* db + .insert(users) + .values({ name }) + .pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to create user"))) + }), + rename: Effect.fn("UserStore.rename")(function* (from: string, to: string) { + yield* db + .transaction( + Effect.fnUntraced(function* (tx) { + yield* tx.insert(users).values({ name: from }) + yield* tx.update(users).set({ name: to }).where(eq(users.name, from)) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to rename user"))) + }), + list: Effect.fn("UserStore.list")(function* () { + return yield* db + .select() + .from(users) + .pipe(Effect.mapError(mapStoreError("Failed to list users"))) + }), + }) + }), + ).pipe(Layer.provide(Database.layer)) +} + +const program = Effect.gen(function* () { + const userStore = yield* UserStore + + yield* userStore.migrate() + yield* userStore.create("Ada") + yield* userStore.rename("Grace", "Grace Hopper") + + return yield* userStore.list() +}) + +const rows = await Effect.runPromise(program.pipe(Effect.provide(UserStore.layer))) + +console.log(rows) diff --git a/packages/effect-drizzle-sqlite/examples/migrations/20240101000000_create_users/migration.sql b/packages/effect-drizzle-sqlite/examples/migrations/20240101000000_create_users/migration.sql new file mode 100644 index 0000000000..f1aa81cb45 --- /dev/null +++ b/packages/effect-drizzle-sqlite/examples/migrations/20240101000000_create_users/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE users ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + name text NOT NULL +); diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json new file mode 100644 index 0000000000..57917e43a3 --- /dev/null +++ b/packages/effect-drizzle-sqlite/package.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.15.5", + "name": "@opencode-ai/effect-drizzle-sqlite", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "test": "bun test --timeout 30000", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts", + "./effect-sqlite": "./src/effect-sqlite/index.ts", + "./effect-sqlite/migrator": "./src/effect-sqlite/migrator.ts", + "./sqlite-core/effect": "./src/sqlite-core/effect/index.ts" + }, + "devDependencies": { + "@effect/sql-sqlite-bun": "catalog:", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "dependencies": { + "drizzle-orm": "catalog:", + "effect": "catalog:" + } +} diff --git a/packages/effect-drizzle-sqlite/src/effect-sqlite/driver.ts b/packages/effect-drizzle-sqlite/src/effect-sqlite/driver.ts new file mode 100644 index 0000000000..f2ffc207d3 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/effect-sqlite/driver.ts @@ -0,0 +1,77 @@ +/* oxlint-disable */ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { SqlClient } from "effect/unstable/sql/SqlClient" +import { EffectCache } from "drizzle-orm/cache/core/cache-effect" +import { EffectLogger } from "drizzle-orm/effect-core" +import { entityKind } from "drizzle-orm/entity" +import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations" +import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect" +import { SQLiteEffectDatabase } from "../sqlite-core/effect/db" +import type { DrizzleConfig } from "drizzle-orm/utils" +import { jitCompatCheck } from "../internal/drizzle-utils" +import { type EffectSQLiteQueryEffectHKT, type EffectSQLiteRunResult, EffectSQLiteSession } from "./session" + +export class EffectSQLiteDatabase extends SQLiteEffectDatabase< + EffectSQLiteQueryEffectHKT, + EffectSQLiteRunResult, + TRelations +> { + static override readonly [entityKind]: string = "EffectSQLiteDatabase" +} + +export type EffectDrizzleSQLiteConfig = Omit< + DrizzleConfig, TRelations>, + "cache" | "logger" | "schema" +> + +export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Default) + +/** + * Creates an EffectSQLiteDatabase instance. + * + * Requires a generic Effect `SqlClient`, `EffectLogger`, and `EffectCache` services to be provided. + * Drizzle only depends on the generic `SqlClient`; install and provide a compatible SQLite provider such as + * `@effect/sql-sqlite-node`, `@effect/sql-sqlite-bun`, or another package that exposes `SqlClient`. + * + * @example + * ```ts + * import { SqliteClient } from '@effect/sql-sqlite-node'; + * import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite'; + * import * as Effect from 'effect/Effect'; + * + * const db = yield* SQLiteDrizzle.make({ relations }).pipe( + * Effect.provide(SQLiteDrizzle.DefaultServices), + * Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })), + * ); + * ``` + */ +export const make = Effect.fn("SQLiteDrizzle.make")(function* ( + config: EffectDrizzleSQLiteConfig = {}, +) { + const client = yield* SqlClient + const cache = yield* EffectCache + const logger = yield* EffectLogger + + const dialect = new SQLiteAsyncDialect() + const relations = config.relations ?? ({} as TRelations) + const session = new EffectSQLiteSession(client, dialect, relations, { + logger, + cache, + useJitMappers: jitCompatCheck(config.jit), + }) + const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase & { + $client: SqlClient + } + db.$client = client + db.$cache.invalidate = cache.onMutate + + return db +}) + +/** + * Convenience function that creates an EffectSQLiteDatabase with `DefaultServices` already provided. + */ +export const makeWithDefaults = ( + config: EffectDrizzleSQLiteConfig = {}, +) => make(config).pipe(Effect.provide(DefaultServices)) diff --git a/packages/effect-drizzle-sqlite/src/effect-sqlite/index.ts b/packages/effect-drizzle-sqlite/src/effect-sqlite/index.ts new file mode 100644 index 0000000000..1133e48b3a --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/effect-sqlite/index.ts @@ -0,0 +1,4 @@ +/* oxlint-disable */ +export { EffectLogger } from "drizzle-orm/effect-core" +export * from "./driver" +export * from "./session" diff --git a/packages/effect-drizzle-sqlite/src/effect-sqlite/migrator.ts b/packages/effect-drizzle-sqlite/src/effect-sqlite/migrator.ts new file mode 100644 index 0000000000..6d0d155143 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/effect-sqlite/migrator.ts @@ -0,0 +1,14 @@ +/* oxlint-disable */ +import type { MigrationConfig } from "drizzle-orm/migrator" +import { readMigrationFiles } from "drizzle-orm/migrator" +import type { AnyRelations } from "drizzle-orm/relations" +import { migrate as coreMigrate } from "../sqlite-core/effect/session" +import type { EffectSQLiteDatabase } from "./driver" + +export function migrate( + db: EffectSQLiteDatabase, + config: MigrationConfig, +) { + const migrations = readMigrationFiles(config) + return coreMigrate(migrations, db.session, config) +} diff --git a/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts b/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts new file mode 100644 index 0000000000..047b50b61f --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts @@ -0,0 +1,214 @@ +/* oxlint-disable */ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as Scope from "effect/Scope" +import type { SqlClient } from "effect/unstable/sql/SqlClient" +import type { SqlError } from "effect/unstable/sql/SqlError" +import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect" +import type { WithCacheConfig } from "drizzle-orm/cache/core/types" +import type { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors" +import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger" +import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind } from "drizzle-orm/entity" +import type { AnyRelations } from "drizzle-orm/relations" +import type { RelationalQueryMapperConfig } from "drizzle-orm/relations" +import type { Query } from "drizzle-orm/sql/sql" +import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect" +import { SQLiteEffectPreparedQuery, SQLiteEffectSession, SQLiteEffectTransaction } from "../sqlite-core/effect/session" +import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session" + +export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase { + readonly error: EffectDrizzleQueryError + readonly context: never +} + +export type EffectSQLiteRunResult = readonly never[] + +export interface EffectSQLiteSessionOptions { + logger: EffectLoggerShape + cache: EffectCacheShape + useJitMappers?: boolean +} + +export class EffectSQLiteSession extends SQLiteEffectSession< + EffectSQLiteQueryEffectHKT, + EffectSQLiteRunResult, + TRelations +> { + static override readonly [entityKind]: string = "EffectSQLiteSession" + + constructor( + private client: SqlClient, + dialect: SQLiteAsyncDialect, + protected relations: TRelations, + private options: EffectSQLiteSessionOptions, + ) { + super(dialect) + } + + override prepareQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown, + queryMetadata?: { + type: "select" | "update" | "delete" | "insert" + tables: string[] + }, + cacheConfig?: WithCacheConfig, + ): SQLiteEffectPreparedQuery { + return new SQLiteEffectPreparedQuery( + (params, method) => this.execute(query, params, method), + query, + this.options.logger, + this.options.cache, + queryMetadata, + cacheConfig, + fields, + executeMethod, + this.options.useJitMappers, + customResultMapper, + undefined, + undefined, + this.isInTransaction(), + ) + } + + override prepareRelationalQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + customResultMapper: (rows: Record[], mapColumnValue?: (value: unknown) => unknown) => unknown, + config: RelationalQueryMapperConfig, + ): SQLiteEffectPreparedQuery { + return new SQLiteEffectPreparedQuery( + (params, method) => this.execute(query, params, method), + query, + this.options.logger, + this.options.cache, + undefined, + undefined, + fields, + executeMethod, + this.options.useJitMappers, + customResultMapper, + true, + config, + this.isInTransaction(), + ) + } + + private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") { + const statement = this.client.unsafe(query.sql, params) + if (method === "values") return statement.values + if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0])) + return statement.withoutTransform + } + + private isInTransaction() { + return Effect.serviceOption(this.client.transactionService).pipe(Effect.map((option) => option._tag === "Some")) + } + + private executeTransactionStatement(connection: Effect.Success, query: string) { + return connection.executeUnprepared(query, [], undefined).pipe(Effect.asVoid) + } + + private withTransaction(effect: Effect.Effect, config: SQLiteTransactionConfig | undefined) { + return Effect.uninterruptibleMask((restore) => + Effect.withFiber((fiber) => { + const services = fiber.context + const connectionOption = Context.getOption(services, this.client.transactionService) + const connection: Effect.Effect< + readonly [Scope.Closeable | undefined, Effect.Success], + SqlError + > = + connectionOption._tag === "Some" + ? Effect.succeed([undefined, connectionOption.value[0]] as const) + : Scope.make().pipe( + Effect.flatMap((scope) => + Scope.provide(this.client.reserve, scope).pipe( + Effect.map((connection) => [scope, connection] as const), + Effect.catch((error) => + Scope.close(scope, Exit.fail(error)).pipe(Effect.andThen(Effect.fail(error))), + ), + ), + ), + ) + const id = connectionOption._tag === "Some" ? connectionOption.value[1] + 1 : 0 + + return connection.pipe( + Effect.flatMap(([scope, connection]) => + this.executeTransactionStatement( + connection, + id === 0 ? `begin ${config?.behavior ?? "deferred"}` : `savepoint effect_sql_${id}`, + ).pipe( + Effect.flatMap(() => + Effect.provideContext( + restore(effect), + Context.add(services, this.client.transactionService, [connection, id]), + ), + ), + Effect.exit, + Effect.flatMap((exit) => { + const finalize = Exit.isSuccess(exit) + ? id === 0 + ? this.executeTransactionStatement(connection, "commit").pipe( + // SQLite keeps the transaction open after deferred constraint commit failures. + Effect.catch((error) => + this.executeTransactionStatement(connection, "rollback").pipe( + Effect.catch(() => Effect.void), + Effect.andThen(Effect.fail(error)), + ), + ), + ) + : this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`) + : id === 0 + ? this.executeTransactionStatement(connection, "rollback") + : this.executeTransactionStatement(connection, `rollback to savepoint effect_sql_${id}`).pipe( + Effect.andThen( + this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`), + ), + ) + const scoped = scope === undefined ? finalize : Effect.ensuring(finalize, Scope.close(scope, exit)) + + return scoped.pipe(Effect.flatMap(() => exit)) + }), + ), + ), + ) + }), + ) + } + + override transaction( + transaction: (tx: EffectSQLiteTransaction) => Effect.Effect, + config?: SQLiteTransactionConfig, + ): Effect.Effect { + const { dialect, relations } = this + + return this.withTransaction( + Effect.gen({ self: this }, function* () { + const tx = new EffectSQLiteTransaction(dialect, this, relations) + + return yield* transaction(tx) + }), + config, + ) + } +} + +export class EffectSQLiteTransaction extends SQLiteEffectTransaction< + EffectSQLiteQueryEffectHKT, + EffectSQLiteRunResult, + TRelations +> { + static override readonly [entityKind]: string = "EffectSQLiteTransaction" + + override transaction: ( + transaction: ( + tx: SQLiteEffectTransaction, + ) => Effect.Effect, + ) => Effect.Effect = (tx) => this.session.transaction(tx) +} diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts new file mode 100644 index 0000000000..d6606b7d9f --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -0,0 +1,6 @@ +export { EffectLogger } from "drizzle-orm/effect-core" +export * from "./effect-sqlite/driver" +export * from "./effect-sqlite/session" +export { migrate } from "./effect-sqlite/migrator" + +export * as EffectDrizzleSqlite from "." diff --git a/packages/effect-drizzle-sqlite/src/internal/drizzle-utils.ts b/packages/effect-drizzle-sqlite/src/internal/drizzle-utils.ts new file mode 100644 index 0000000000..1998a08318 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/internal/drizzle-utils.ts @@ -0,0 +1,127 @@ +/* oxlint-disable */ +import { Column, getColumnTable } from "drizzle-orm/column" +import { is } from "drizzle-orm/entity" +import type { JoinNullability } from "drizzle-orm/query-builders/select.types" +import { Param, SQL } from "drizzle-orm/sql/sql" +import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update" +import type { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base" +import { Subquery } from "drizzle-orm/subquery" +import { Table, getTableName } from "drizzle-orm/table" +import type { UpdateSet } from "drizzle-orm/utils" +import { ViewBaseConfig } from "drizzle-orm/view-common" + +const TableSymbol = ( + Table as unknown as { + Symbol: { Columns: symbol; IsAlias: symbol; Name: symbol; BaseName: symbol } + } +).Symbol + +export function getTableColumnsRuntime(table: SQLiteTable) { + return (table as unknown as Record>)[TableSymbol.Columns] +} + +export function getViewSelectedFieldsRuntime(view: SQLiteViewBase) { + return (view as unknown as Record; name: string }>)[ViewBaseConfig] +} + +export function jitCompatCheck(isEnabled: boolean | undefined) { + if (!isEnabled) return false + try { + return new Function("input", '"use strict"; return input;')(true) === true + } catch { + return false + } +} + +export function orderSelectedFields( + fields: Record, + pathPrefix?: string[], +): SelectedFieldsOrdered { + return Object.entries(fields).flatMap(([name, field]) => { + const path = pathPrefix ? [...pathPrefix, name] : [name] + if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) { + return [{ path, field }] as SelectedFieldsOrdered + } + if (is(field, Table)) return orderSelectedFields(getTableColumnsRuntime(field as SQLiteTable), path) + return orderSelectedFields(field as Record, path) + }) as SelectedFieldsOrdered +} + +export function mapUpdateSet(table: TTable, values: SQLiteUpdateSetSource) { + const entries = Object.entries(values).filter(([, value]) => value !== undefined) + if (entries.length === 0) throw new Error("No values to set") + + return Object.fromEntries( + entries.map(([key, value]) => [ + key, + is(value, SQL) || is(value, Column) ? value : new Param(value, getTableColumnsRuntime(table)[key]), + ]), + ) as UpdateSet +} + +export function mapResultRow( + columns: SelectedFieldsOrdered, + row: unknown[], + joinsNotNullableMap: Record | undefined, +) { + const nullifyMap: Record = {} + const result: Record = {} + + columns.forEach((column, columnIndex) => { + const decoder = ( + is(column.field, Column) + ? column.field + : is(column.field, SQL) + ? (column.field as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder + : is(column.field, Subquery) + ? (column.field._.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder + : (column.field.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder + ) as { + mapFromDriverValue(value: unknown): unknown + } + const rawValue = row[columnIndex] + const value = rawValue === null ? null : decoder.mapFromDriverValue(rawValue) + const objectName = column.path[0] + let node = result + + column.path.forEach((pathChunk, pathChunkIndex) => { + if (pathChunkIndex === column.path.length - 1) { + node[pathChunk] = value + return + } + node[pathChunk] = (node[pathChunk] ?? {}) as Record + node = node[pathChunk] as Record + }) + + if (joinsNotNullableMap && is(column.field, Column) && column.path.length === 2 && objectName) { + const tableName = getTableName(getColumnTable(column.field)) + nullifyMap[objectName] = + !(objectName in nullifyMap) && value === null + ? tableName + : typeof nullifyMap[objectName] === "string" && nullifyMap[objectName] !== tableName + ? false + : nullifyMap[objectName] + } + }) + + Object.entries(nullifyMap).forEach(([objectName, tableName]) => { + if (typeof tableName === "string" && !joinsNotNullableMap?.[tableName]) result[objectName] = null + }) + + return result +} + +export function getTableLikeName(table: SQLiteTable | Subquery | SQLiteViewBase | SQL) { + if (is(table, Subquery)) return table._.alias + if (is(table, SQLiteViewBase)) return getViewSelectedFieldsRuntime(table).name + if (is(table, SQL)) return undefined + return (table as unknown as Record)[ + (table as unknown as Record)[TableSymbol.IsAlias] + ? TableSymbol.Name + : TableSymbol.BaseName + ] as string +} + +export type { JoinNullability } diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/count.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/count.ts new file mode 100644 index 0000000000..c420d6fbb0 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/count.ts @@ -0,0 +1,58 @@ +/* oxlint-disable */ +import type * as Effect from "effect/Effect" +import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind } from "drizzle-orm/entity" +import { SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql" +import type { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import type { SQLiteView } from "drizzle-orm/sqlite-core/view" +import type { SQLiteEffectSession } from "./session" + +function buildSQLiteEmbeddedCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL) { + return sql`(select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters})` +} + +function buildSQLiteCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL) { + return sql`select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters}` +} + +export interface SQLiteEffectCountBuilder + extends SQL, + SQLWrapper, + Effect.Effect {} + +export class SQLiteEffectCountBuilder extends SQL { + static override readonly [entityKind]: string = "SQLiteEffectCountBuilder" + + private sql: SQL + private session: SQLiteEffectSession + + constructor(params: { + source: SQLiteTable | SQLiteView | SQL | SQLWrapper + filters?: SQL + session: SQLiteEffectSession + }) { + super(buildSQLiteEmbeddedCount(params.source, params.filters).queryChunks) + + this.session = params.session + this.sql = buildSQLiteCount(params.source, params.filters) + } + + execute(placeholderValues?: Record) { + return this.session + .prepareQuery<{ + type: "async" + execute: number + run: unknown + all: unknown + get: unknown + values: unknown + }>(this.session.dialect.sqlToQuery(this.sql), undefined, "all", (rows) => { + const v = rows[0]?.[0] + if (typeof v === "number") return v + return v ? Number(v) : 0 + }) + .execute(placeholderValues) + } +} + +applyEffectWrapper(SQLiteEffectCountBuilder) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/db.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/db.ts new file mode 100644 index 0000000000..ac4cc140ad --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/db.ts @@ -0,0 +1,296 @@ +/* oxlint-disable */ +import { Effect } from "effect" +import type { SqlError } from "effect/unstable/sql/SqlError" +import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect" +import type { MutationOption } from "drizzle-orm/cache/core/cache" +import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind } from "drizzle-orm/entity" +import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder" +import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations" +import { SelectionProxyHandler } from "drizzle-orm/selection-proxy" +import { type ColumnsSelection, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql" +import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect" +import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder" +import type { SelectedFields } from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session" +import type { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import type { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base" +import { WithSubquery } from "drizzle-orm/subquery" +import type { WithBuilder } from "drizzle-orm/sqlite-core/subquery" +import { SQLiteEffectCountBuilder } from "./count" +import { SQLiteEffectDeleteBase } from "./delete" +import { SQLiteEffectInsertBuilder } from "./insert" +import { SQLiteEffectRelationalQueryBuilder } from "./query" +import { SQLiteEffectRaw } from "./raw" +import { SQLiteEffectSelectBuilder } from "./select" +import type { SQLiteEffectSelectBase } from "./select" +import type { SQLiteEffectSession, SQLiteEffectTransaction } from "./session" +import { SQLiteEffectUpdateBuilder } from "./update" + +export class SQLiteEffectDatabase< + TEffectHKT extends QueryEffectHKTBase, + TRunResult, + TRelations extends AnyRelations = EmptyRelations, +> { + static readonly [entityKind]: string = "SQLiteEffectDatabase" + + declare readonly _: { + readonly relations: TRelations + readonly session: SQLiteEffectSession + } + + query: { + [K in keyof TRelations]: SQLiteEffectRelationalQueryBuilder + } + + constructor( + /** @internal */ + readonly dialect: SQLiteAsyncDialect, + /** @internal */ + readonly session: SQLiteEffectSession, + relations: TRelations, + readonly rowModeRQB?: boolean, + readonly forbidJsonb?: boolean, + ) { + this._ = { + relations, + session, + } + + this.query = {} as (typeof this)["query"] + for (const [tableName, relation] of Object.entries(relations)) { + ;(this.query as SQLiteEffectDatabase["query"])[tableName] = + new SQLiteEffectRelationalQueryBuilder( + relations, + relations[relation.name]!.table as SQLiteTable, + relation, + dialect, + session, + rowModeRQB, + forbidJsonb, + ) + } + + this.$cache = { + invalidate: (_params: MutationOption) => Effect.void, + } + } + + $with: WithBuilder = (alias: string, selection?: ColumnsSelection) => { + const self = this + const as = ( + qb: + | TypedQueryBuilder + | SQL + | ((qb: QueryBuilder) => TypedQueryBuilder | SQL), + ) => { + if (typeof qb === "function") { + qb = qb(new QueryBuilder(self.dialect)) + } + + return new Proxy( + new WithSubquery( + qb.getSQL(), + selection ?? + (("getSelectedFields" in qb + ? ((qb as { getSelectedFields(): SelectedFields | undefined }).getSelectedFields() ?? {}) + : {}) as SelectedFields), + alias, + true, + ), + new SelectionProxyHandler({ alias, sqlAliasedBehavior: "alias", sqlBehavior: "error" }), + ) + } + return { as } + } + + $cache: { invalidate: EffectCacheShape["onMutate"] } + + $count(source: SQLiteTable | SQLiteViewBase | SQL | SQLWrapper, filters?: SQL) { + return new SQLiteEffectCountBuilder({ source, filters, session: this.session }) + } + + with(...queries: WithSubquery[]) { + const self = this + + function select(): SQLiteEffectSelectBuilder + function select( + fields: TSelection, + ): SQLiteEffectSelectBuilder + function select( + fields?: SelectedFields, + ): SQLiteEffectSelectBuilder { + return new SQLiteEffectSelectBuilder({ + fields: fields ?? undefined, + session: self.session, + dialect: self.dialect, + withList: queries, + }) + } + + function selectDistinct(): SQLiteEffectSelectBuilder + function selectDistinct( + fields: TSelection, + ): SQLiteEffectSelectBuilder + function selectDistinct( + fields?: SelectedFields, + ): SQLiteEffectSelectBuilder { + return new SQLiteEffectSelectBuilder({ + fields: fields ?? undefined, + session: self.session, + dialect: self.dialect, + withList: queries, + distinct: true, + }) + } + + function update( + table: TTable, + ): SQLiteEffectUpdateBuilder { + return new SQLiteEffectUpdateBuilder(table, self.session, self.dialect, queries) + } + + function insert( + into: TTable, + ): SQLiteEffectInsertBuilder { + return new SQLiteEffectInsertBuilder(into, self.session, self.dialect, queries) + } + + function delete_( + from: TTable, + ): SQLiteEffectDeleteBase { + return new SQLiteEffectDeleteBase(from, self.session, self.dialect, queries) + } + + return { select, selectDistinct, update, insert, delete: delete_ } + } + + select(): SQLiteEffectSelectBuilder + select( + fields: TSelection, + ): SQLiteEffectSelectBuilder + select(fields?: SelectedFields): SQLiteEffectSelectBuilder { + return new SQLiteEffectSelectBuilder({ fields: fields ?? undefined, session: this.session, dialect: this.dialect }) + } + + selectDistinct(): SQLiteEffectSelectBuilder + selectDistinct( + fields: TSelection, + ): SQLiteEffectSelectBuilder + selectDistinct( + fields?: SelectedFields, + ): SQLiteEffectSelectBuilder { + return new SQLiteEffectSelectBuilder({ + fields: fields ?? undefined, + session: this.session, + dialect: this.dialect, + distinct: true, + }) + } + + update(table: TTable): SQLiteEffectUpdateBuilder { + return new SQLiteEffectUpdateBuilder(table, this.session, this.dialect) + } + + insert(into: TTable): SQLiteEffectInsertBuilder { + return new SQLiteEffectInsertBuilder(into, this.session, this.dialect) + } + + delete( + from: TTable, + ): SQLiteEffectDeleteBase { + return new SQLiteEffectDeleteBase(from, this.session, this.dialect) + } + + private raw( + query: SQLWrapper | string, + action: "all" | "get" | "run" | "values", + execute: (query: SQL) => Effect.Effect, + ): SQLiteEffectRaw { + const sequel = typeof query === "string" ? sql.raw(query) : query.getSQL() + return new SQLiteEffectRaw( + () => execute(sequel), + () => sequel, + action, + this.dialect, + (result) => result, + ) + } + + run(query: SQLWrapper | string): SQLiteEffectRaw { + return this.raw(query, "run", (sequel) => this.session.run(sequel)) + } + + all(query: SQLWrapper | string): SQLiteEffectRaw { + return this.raw(query, "all", (sequel) => this.session.all(sequel)) + } + + get(query: SQLWrapper | string): SQLiteEffectRaw { + return this.raw(query, "get", (sequel) => this.session.get(sequel)) + } + + values(query: SQLWrapper | string): SQLiteEffectRaw { + return this.raw(query, "values", (sequel) => this.session.values(sequel)) + } + + transaction: ( + transaction: (tx: SQLiteEffectTransaction) => Effect.Effect, + config?: SQLiteTransactionConfig, + ) => Effect.Effect = (tx, config) => this.session.transaction(tx, config) +} + +export type SQLiteEffectWithReplicas = Q & { $primary: Q; $replicas: Q[] } + +export const withReplicas = < + TEffectHKT extends QueryEffectHKTBase, + TRunResult, + TRelations extends AnyRelations, + Q extends SQLiteEffectDatabase, +>( + primary: Q, + replicas: [Q, ...Q[]], + getReplica: (replicas: Q[]) => Q = () => replicas[Math.floor(Math.random() * replicas.length)]!, +): SQLiteEffectWithReplicas => { + const select: Q["select"] = (...args: []) => getReplica(replicas).select(...args) + const selectDistinct: Q["selectDistinct"] = (...args: []) => getReplica(replicas).selectDistinct(...args) + const $count: Q["$count"] = (...args: [any]) => getReplica(replicas).$count(...args) + const _with: Q["with"] = (...args: []) => getReplica(replicas).with(...args) + const $with = ((...args: [string] | [string, ColumnsSelection]) => + args.length === 1 + ? getReplica(replicas).$with(args[0]) + : getReplica(replicas).$with(args[0], args[1])) as Q["$with"] + + const update: Q["update"] = (...args: [any]) => primary.update(...args) + const insert: Q["insert"] = (...args: [any]) => primary.insert(...args) + const $delete: Q["delete"] = (...args: [any]) => primary.delete(...args) + const run: Q["run"] = (...args: [any]) => primary.run(...args) + const all: Q["all"] = (...args: [any]) => primary.all(...args) + const get: Q["get"] = (...args: [any]) => primary.get(...args) + const values: Q["values"] = (...args: [any]) => primary.values(...args) + const transaction: Q["transaction"] = (...args: [any]) => primary.transaction(...args) + + return { + ...primary, + update, + insert, + delete: $delete, + run, + all, + get, + values, + transaction, + $primary: primary, + $replicas: replicas, + select, + selectDistinct, + $count, + $with, + with: _with, + get query() { + return getReplica(replicas).query + }, + } +} + +export type AnySQLiteEffectDatabase = SQLiteEffectDatabase +export type AnySQLiteEffectSelectBase = SQLiteEffectSelectBase diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/delete.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/delete.ts new file mode 100644 index 0000000000..15e90d1974 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/delete.ts @@ -0,0 +1,261 @@ +/* oxlint-disable */ +import type * as Effect from "effect/Effect" +import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind } from "drizzle-orm/entity" +import type { SelectResultFields } from "drizzle-orm/query-builders/select.types" +import type { RunnableQuery } from "drizzle-orm/runnable-query" +import { SelectionProxyHandler } from "drizzle-orm/selection-proxy" +import type { Placeholder, Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql" +import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect" +import type { SQLiteDeleteConfig } from "drizzle-orm/sqlite-core/query-builders/delete" +import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session" +import { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import { extractUsedTable } from "drizzle-orm/sqlite-core/utils" +import type { Subquery } from "drizzle-orm/subquery" +import { type DrizzleTypeError, type ValueOrArray } from "drizzle-orm/utils" +import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common" +import { getTableColumnsRuntime, orderSelectedFields } from "../../internal/drizzle-utils" +import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session" + +export type SQLiteEffectDeleteWithout< + T extends AnySQLiteEffectDelete, + TDynamic extends boolean, + K extends keyof T & string, +> = TDynamic extends true + ? T + : Omit< + SQLiteEffectDeleteBase< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["returning"], + TDynamic, + T["_"]["excludedMethods"] | K, + T["_"]["effectHKT"] + >, + T["_"]["excludedMethods"] | K + > + +export type SQLiteEffectDeleteReturningAll< + T extends AnySQLiteEffectDelete, + TDynamic extends boolean, +> = SQLiteEffectDeleteWithout< + SQLiteEffectDeleteBase< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["table"]["$inferSelect"], + T["_"]["dynamic"], + T["_"]["excludedMethods"], + T["_"]["effectHKT"] + >, + TDynamic, + "returning" +> + +export type SQLiteEffectDeleteReturning< + T extends AnySQLiteEffectDelete, + TDynamic extends boolean, + TSelectedFields extends SelectedFieldsFlat, +> = SQLiteEffectDeleteWithout< + SQLiteEffectDeleteBase< + T["_"]["table"], + T["_"]["runResult"], + SelectResultFields, + T["_"]["dynamic"], + T["_"]["excludedMethods"], + T["_"]["effectHKT"] + >, + TDynamic, + "returning" +> + +export type SQLiteEffectDeleteExecute = T["_"]["returning"] extends undefined + ? T["_"]["runResult"] + : T["_"]["returning"][] + +export type SQLiteEffectDeletePrepare< + T extends AnySQLiteEffectDelete, + TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"], +> = SQLiteEffectPreparedQuery< + PreparedQueryConfig & { + run: T["_"]["runResult"] + all: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".all() cannot be used without .returning()"> + : T["_"]["returning"][] + get: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".get() cannot be used without .returning()"> + : T["_"]["returning"] | undefined + values: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".values() cannot be used without .returning()"> + : any[][] + execute: SQLiteEffectDeleteExecute + }, + TEffectHKT +> + +export type SQLiteEffectDeleteDynamic = SQLiteEffectDelete< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["returning"], + T["_"]["effectHKT"] +> + +export type SQLiteEffectDelete< + TTable extends SQLiteTable = SQLiteTable, + TRunResult = unknown, + TReturning extends Record | undefined = undefined, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> = SQLiteEffectDeleteBase + +export type AnySQLiteEffectDelete = SQLiteEffectDeleteBase + +export interface SQLiteEffectDeleteBase< + TTable extends SQLiteTable, + TRunResult, + TReturning extends Record | undefined = undefined, + TDynamic extends boolean = false, + _TExcludedMethods extends string = never, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> extends RunnableQuery, + SQLWrapper, + Effect.Effect< + TReturning extends undefined ? TRunResult : TReturning[], + TEffectHKT["error"], + TEffectHKT["context"] + > { + readonly _: { + dialect: "sqlite" + readonly table: TTable + readonly resultType: "async" + readonly runResult: TRunResult + readonly returning: TReturning + readonly dynamic: TDynamic + readonly excludedMethods: _TExcludedMethods + readonly result: TReturning extends undefined ? TRunResult : TReturning[] + readonly effectHKT: TEffectHKT + } +} + +export class SQLiteEffectDeleteBase< + TTable extends SQLiteTable, + TRunResult, + TReturning extends Record | undefined = undefined, + TDynamic extends boolean = false, + _TExcludedMethods extends string = never, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, + > + implements RunnableQuery, SQLWrapper +{ + static readonly [entityKind]: string = "SQLiteEffectDelete" + + /** @internal */ + config: SQLiteDeleteConfig + + constructor( + private table: TTable, + private effectSession: SQLiteEffectSession, + private effectDialect: SQLiteDialect, + withList?: Subquery[], + ) { + this.config = { table, withList } + } + + where(where: SQL | undefined): SQLiteEffectDeleteWithout { + this.config.where = where + return this as any + } + + orderBy( + builder: (deleteTable: TTable) => ValueOrArray, + ): SQLiteEffectDeleteWithout + orderBy(...columns: (SQLiteColumn | SQL | SQL.Aliased)[]): SQLiteEffectDeleteWithout + orderBy( + ...columns: + | [(deleteTable: TTable) => ValueOrArray] + | (SQLiteColumn | SQL | SQL.Aliased)[] + ): SQLiteEffectDeleteWithout { + if (typeof columns[0] === "function") { + const orderBy = columns[0]( + new Proxy( + getTableColumnsRuntime(this.config.table), + new SelectionProxyHandler({ sqlAliasedBehavior: "alias", sqlBehavior: "sql" }), + ) as any, + ) + + this.config.orderBy = Array.isArray(orderBy) ? orderBy : [orderBy] + return this as any + } + + this.config.orderBy = columns as (SQLiteColumn | SQL | SQL.Aliased)[] + return this as any + } + + limit(limit: number | Placeholder): SQLiteEffectDeleteWithout { + this.config.limit = limit + return this as any + } + + returning(): SQLiteEffectDeleteReturningAll + returning( + fields: TSelectedFields, + ): SQLiteEffectDeleteReturning + returning( + fields: SelectedFieldsFlat = getTableColumnsRuntime(this.table), + ): SQLiteEffectDeleteReturning | SQLiteEffectDeleteReturningAll { + this.config.returning = orderSelectedFields(fields) + return this as any + } + + /** @internal */ + getSQL(): SQL { + return this.effectDialect.buildDeleteQuery(this.config) + } + + toSQL(): Query { + return this.effectDialect.sqlToQuery(this.getSQL()) + } + + /** @internal */ + _prepare(isOneTimeQuery = true): SQLiteEffectDeletePrepare { + return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"]( + this.effectDialect.sqlToQuery(this.getSQL()), + this.config.returning, + this.config.returning ? "all" : "run", + undefined, + { + type: "delete", + tables: extractUsedTable(this.config.table), + }, + ) as SQLiteEffectDeletePrepare + } + + prepare(): SQLiteEffectDeletePrepare { + return this._prepare(false) + } + + run: ReturnType["run"] = (placeholderValues) => { + return this._prepare().run(placeholderValues) + } + + all: ReturnType["all"] = (placeholderValues) => { + return this._prepare().all(placeholderValues) + } + + get: ReturnType["get"] = (placeholderValues) => { + return this._prepare().get(placeholderValues) + } + + values: ReturnType["values"] = (placeholderValues) => { + return this._prepare().values(placeholderValues) + } + + execute: ReturnType["execute"] = (placeholderValues) => { + return this._prepare().execute(placeholderValues) + } + + $dynamic(): SQLiteEffectDeleteDynamic { + return this as any + } +} + +applyEffectWrapper(SQLiteEffectDeleteBase) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/index.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/index.ts new file mode 100644 index 0000000000..ab7aeb1ab6 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/index.ts @@ -0,0 +1,10 @@ +/* oxlint-disable */ +export * from "./count" +export * from "./db" +export * from "./delete" +export * from "./insert" +export * from "./query" +export * from "./raw" +export * from "./select" +export * from "./session" +export * from "./update" diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/insert.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/insert.ts new file mode 100644 index 0000000000..f85dc24475 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/insert.ts @@ -0,0 +1,349 @@ +/* oxlint-disable */ +import type * as Effect from "effect/Effect" +import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind, is } from "drizzle-orm/entity" +import type { SelectResultFields } from "drizzle-orm/query-builders/select.types" +import type { RunnableQuery } from "drizzle-orm/runnable-query" +import type { Query, SQLWrapper } from "drizzle-orm/sql/sql" +import { Param, SQL, sql } from "drizzle-orm/sql/sql" +import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect" +import type { IndexColumn } from "drizzle-orm/sqlite-core/indexes" +import type { + SQLiteInsertConfig, + SQLiteInsertSelectQueryBuilder, + SQLiteInsertValue, +} from "drizzle-orm/sqlite-core/query-builders/insert" +import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session" +import { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import { extractUsedTable } from "drizzle-orm/sqlite-core/utils" +import type { Subquery } from "drizzle-orm/subquery" +import { type DrizzleTypeError, haveSameKeys } from "drizzle-orm/utils" +import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common" +import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder" +import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update" +import { getTableColumnsRuntime, mapUpdateSet, orderSelectedFields } from "../../internal/drizzle-utils" +import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session" + +export type SQLiteEffectInsertWithout< + T extends AnySQLiteEffectInsert, + TDynamic extends boolean, + K extends keyof T & string, +> = TDynamic extends true + ? T + : Omit< + SQLiteEffectInsertBase< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["returning"], + TDynamic, + T["_"]["excludedMethods"] | K, + T["_"]["effectHKT"] + >, + T["_"]["excludedMethods"] | K + > + +export type SQLiteEffectInsertReturning< + T extends AnySQLiteEffectInsert, + TDynamic extends boolean, + TSelectedFields extends SelectedFieldsFlat, +> = SQLiteEffectInsertWithout< + SQLiteEffectInsertBase< + T["_"]["table"], + T["_"]["runResult"], + SelectResultFields, + TDynamic, + T["_"]["excludedMethods"], + T["_"]["effectHKT"] + >, + TDynamic, + "returning" +> + +export type SQLiteEffectInsertReturningAll< + T extends AnySQLiteEffectInsert, + TDynamic extends boolean, +> = SQLiteEffectInsertWithout< + SQLiteEffectInsertBase< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["table"]["$inferSelect"], + TDynamic, + T["_"]["excludedMethods"], + T["_"]["effectHKT"] + >, + TDynamic, + "returning" +> + +export type SQLiteEffectInsertDynamic = SQLiteEffectInsert< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["returning"], + T["_"]["effectHKT"] +> + +export type SQLiteEffectInsertOnConflictDoUpdateConfig = { + target: IndexColumn | IndexColumn[] + /** @deprecated - use either `targetWhere` or `setWhere` */ + where?: SQL + targetWhere?: SQL + setWhere?: SQL + set: SQLiteUpdateSetSource +} + +export type SQLiteEffectInsertExecute = T["_"]["returning"] extends undefined + ? T["_"]["runResult"] + : T["_"]["returning"][] + +export type SQLiteEffectInsertPrepare< + T extends AnySQLiteEffectInsert, + TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"], +> = SQLiteEffectPreparedQuery< + PreparedQueryConfig & { + run: T["_"]["runResult"] + all: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".all() cannot be used without .returning()"> + : T["_"]["returning"][] + get: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".get() cannot be used without .returning()"> + : T["_"]["returning"] + values: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".values() cannot be used without .returning()"> + : any[][] + execute: SQLiteEffectInsertExecute + }, + TEffectHKT +> + +export type SQLiteEffectInsert< + TTable extends SQLiteTable = SQLiteTable, + TRunResult = unknown, + TReturning = any, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> = SQLiteEffectInsertBase + +export type AnySQLiteEffectInsert = SQLiteEffectInsertBase + +export class SQLiteEffectInsertBuilder< + TTable extends SQLiteTable, + TRunResult, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> { + static readonly [entityKind]: string = "SQLiteEffectInsertBuilder" + + constructor( + protected table: TTable, + protected session: SQLiteEffectSession, + protected dialect: SQLiteDialect, + private withList?: Subquery[], + ) {} + + values( + value: SQLiteInsertValue, + ): SQLiteEffectInsertBase + values( + values: SQLiteInsertValue[], + ): SQLiteEffectInsertBase + values( + values: SQLiteInsertValue | SQLiteInsertValue[], + ): SQLiteEffectInsertBase { + values = Array.isArray(values) ? values : [values] + if (values.length === 0) { + throw new Error("values() must be called with at least one value") + } + const mappedValues = values.map((entry) => { + const result: Record = {} + const cols = getTableColumnsRuntime(this.table) + for (const colKey of Object.keys(entry)) { + const colValue = entry[colKey as keyof typeof entry] + result[colKey] = is(colValue, SQL) ? colValue : new Param(colValue, cols[colKey]) + } + return result + }) + + return new SQLiteEffectInsertBase(this.table, mappedValues, this.session, this.dialect, this.withList) + } + + select( + selectQuery: (qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder, + ): SQLiteEffectInsertBase + select( + selectQuery: (qb: QueryBuilder) => SQL, + ): SQLiteEffectInsertBase + select(selectQuery: SQL): SQLiteEffectInsertBase + select( + selectQuery: SQLiteInsertSelectQueryBuilder, + ): SQLiteEffectInsertBase + select( + selectQuery: + | SQL + | SQLiteInsertSelectQueryBuilder + | ((qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder | SQL), + ): SQLiteEffectInsertBase { + const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery + + if (!is(select, SQL) && !haveSameKeys(getTableColumnsRuntime(this.table), select._.selectedFields)) { + throw new Error( + "Insert select error: selected fields are not the same or are in a different order compared to the table definition", + ) + } + + return new SQLiteEffectInsertBase(this.table, select, this.session, this.dialect, this.withList, true) + } +} + +export interface SQLiteEffectInsertBase< + TTable extends SQLiteTable, + TRunResult, + TReturning = undefined, + TDynamic extends boolean = false, + _TExcludedMethods extends string = never, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> extends SQLWrapper, + RunnableQuery, + Effect.Effect< + TReturning extends undefined ? TRunResult : TReturning[], + TEffectHKT["error"], + TEffectHKT["context"] + > { + readonly _: { + readonly dialect: "sqlite" + readonly table: TTable + readonly resultType: "async" + readonly runResult: TRunResult + readonly returning: TReturning + readonly dynamic: TDynamic + readonly excludedMethods: _TExcludedMethods + readonly result: TReturning extends undefined ? TRunResult : TReturning[] + readonly effectHKT: TEffectHKT + } +} + +export class SQLiteEffectInsertBase< + TTable extends SQLiteTable, + TRunResult, + TReturning = undefined, + TDynamic extends boolean = false, + _TExcludedMethods extends string = never, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, + > + implements RunnableQuery, SQLWrapper +{ + static readonly [entityKind]: string = "SQLiteEffectInsert" + + /** @internal */ + config: SQLiteInsertConfig + + constructor( + private table: TTable, + values: SQLiteInsertConfig["values"], + private effectSession: SQLiteEffectSession, + private effectDialect: SQLiteDialect, + withList?: Subquery[], + select?: boolean, + ) { + this.config = { table, values: values as any, withList, select } + } + + returning(): SQLiteEffectInsertReturningAll + returning( + fields: TSelectedFields, + ): SQLiteEffectInsertReturning + returning( + fields: SelectedFieldsFlat = getTableColumnsRuntime(this.config.table), + ): SQLiteEffectInsertWithout { + this.config.returning = orderSelectedFields(fields) + return this as any + } + + onConflictDoNothing(config: { target?: IndexColumn | IndexColumn[]; where?: SQL } = {}): this { + if (!this.config.onConflict) this.config.onConflict = [] + + if (config.target === undefined) { + this.config.onConflict.push(sql` on conflict do nothing`) + return this + } + + const targetSql = Array.isArray(config.target) ? sql`${config.target}` : sql`${[config.target]}` + const whereSql = config.where ? sql` where ${config.where}` : sql`` + this.config.onConflict.push(sql` on conflict ${targetSql} do nothing${whereSql}`) + return this + } + + onConflictDoUpdate(config: SQLiteEffectInsertOnConflictDoUpdateConfig): this { + if (config.where && (config.targetWhere || config.setWhere)) { + throw new Error( + 'You cannot use both "where" and "targetWhere"/"setWhere" at the same time - "where" is deprecated, use "targetWhere" or "setWhere" instead.', + ) + } + + if (!this.config.onConflict) this.config.onConflict = [] + + const whereSql = config.where ? sql` where ${config.where}` : undefined + const targetWhereSql = config.targetWhere ? sql` where ${config.targetWhere}` : undefined + const setWhereSql = config.setWhere ? sql` where ${config.setWhere}` : undefined + const targetSql = Array.isArray(config.target) ? sql`${config.target}` : sql`${[config.target]}` + const setSql = this.effectDialect.buildUpdateSet( + this.config.table, + mapUpdateSet(this.config.table, config.set as SQLiteUpdateSetSource), + ) + this.config.onConflict.push( + sql` on conflict ${targetSql}${targetWhereSql} do update set ${setSql}${whereSql}${setWhereSql}`, + ) + return this + } + + /** @internal */ + getSQL(): SQL { + return this.effectDialect.buildInsertQuery(this.config) + } + + toSQL(): Query { + return this.effectDialect.sqlToQuery(this.getSQL()) + } + + /** @internal */ + _prepare(isOneTimeQuery = true): SQLiteEffectInsertPrepare { + return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"]( + this.effectDialect.sqlToQuery(this.getSQL()), + this.config.returning, + this.config.returning ? "all" : "run", + undefined, + { + type: "insert", + tables: extractUsedTable(this.config.table), + }, + ) as SQLiteEffectInsertPrepare + } + + prepare(): SQLiteEffectInsertPrepare { + return this._prepare(false) + } + + run: ReturnType["run"] = (placeholderValues) => { + return this._prepare().run(placeholderValues) + } + + all: ReturnType["all"] = (placeholderValues) => { + return this._prepare().all(placeholderValues) + } + + get: ReturnType["get"] = (placeholderValues) => { + return this._prepare().get(placeholderValues) + } + + values: ReturnType["values"] = (placeholderValues) => { + return this._prepare().values(placeholderValues) + } + + execute: ReturnType["execute"] = (placeholderValues) => { + return this._prepare().execute(placeholderValues) + } + + $dynamic(): SQLiteEffectInsertDynamic { + return this as any + } +} + +applyEffectWrapper(SQLiteEffectInsertBase) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/query.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/query.ts new file mode 100644 index 0000000000..4676039e55 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/query.ts @@ -0,0 +1,198 @@ +/* oxlint-disable */ +import type * as Effect from "effect/Effect" +import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind } from "drizzle-orm/entity" +import { + type BuildQueryResult, + type BuildRelationalQueryResult, + type DBQueryConfig, + makeDefaultRqbMapper, + type TableRelationalConfig, + type TablesRelationalConfig, +} from "drizzle-orm/relations" +import type { RunnableQuery } from "drizzle-orm/runnable-query" +import { type Query, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql" +import type { KnownKeysOnly } from "drizzle-orm/utils" +import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect" +import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session" +import type { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session" + +export class SQLiteEffectRelationalQueryBuilder< + TSchema extends TablesRelationalConfig, + TFields extends TableRelationalConfig, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> { + static readonly [entityKind]: string = "SQLiteEffectRelationalQueryBuilderV2" + + constructor( + private schema: TSchema, + private table: SQLiteTable, + private tableConfig: TableRelationalConfig, + private dialect: SQLiteDialect, + private session: SQLiteEffectSession, + private rowMode?: boolean, + private forbidJsonb?: boolean, + ) {} + + findMany>( + config?: KnownKeysOnly>, + ): SQLiteEffectRelationalQuery[], TEffectHKT> { + return new SQLiteEffectRelationalQuery( + this.schema, + this.table, + this.tableConfig, + this.dialect, + this.session, + (config as DBQueryConfig<"many"> | undefined) ?? true, + "many", + this.rowMode, + this.forbidJsonb, + ) + } + + findFirst>( + config?: KnownKeysOnly>, + ): SQLiteEffectRelationalQuery | undefined, TEffectHKT> { + return new SQLiteEffectRelationalQuery( + this.schema, + this.table, + this.tableConfig, + this.dialect, + this.session, + (config as DBQueryConfig<"one"> | undefined) ?? true, + "first", + this.rowMode, + this.forbidJsonb, + ) + } +} + +export interface SQLiteEffectRelationalQuery + extends Effect.Effect, + RunnableQuery, + SQLWrapper {} + +export class SQLiteEffectRelationalQuery + implements RunnableQuery, SQLWrapper +{ + static readonly [entityKind]: string = "SQLiteEffectRelationalQueryV2" + + declare readonly _: { + readonly dialect: "sqlite" + readonly type: "async" + readonly result: TResult + } + + /** @internal */ + mode: "many" | "first" + /** @internal */ + table: SQLiteTable + + constructor( + private schema: TablesRelationalConfig, + table: SQLiteTable, + private tableConfig: TableRelationalConfig, + private dialect: SQLiteDialect, + private session: SQLiteEffectSession, + private config: DBQueryConfig<"many" | "one"> | true, + mode: "many" | "first", + private rowMode?: boolean, + private forbidJsonb?: boolean, + ) { + this.mode = mode + this.table = table + } + + /** @internal */ + getSQL(): SQL { + return this._getQuery().sql + } + + /** @internal */ + _prepare( + isOneTimeQuery = true, + ): SQLiteEffectPreparedQuery< + PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult }, + TEffectHKT, + true + > { + const { query, builtQuery } = this._toSQL() + const mapperConfig = { + isFirst: this.mode === "first", + parseJson: !this.rowMode, + parseJsonIfString: false, + rootJsonMappers: true, + selection: query.selection, + } + + return this.session[isOneTimeQuery ? "prepareOneTimeRelationalQuery" : "prepareRelationalQuery"]( + builtQuery, + undefined, + this.mode === "first" ? "get" : "all", + makeDefaultRqbMapper(mapperConfig), + mapperConfig, + ) as SQLiteEffectPreparedQuery< + PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult }, + TEffectHKT, + true + > + } + + prepare(): SQLiteEffectPreparedQuery< + PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult }, + TEffectHKT, + true + > { + return this._prepare(false) + } + + private _getQuery() { + const jsonb = this.forbidJsonb ? sql`json` : sql`jsonb` + + const query = this.dialect.buildRelationalQuery({ + schema: this.schema, + table: this.table, + tableConfig: this.tableConfig, + queryConfig: this.config, + mode: this.mode, + isNested: this.rowMode, + jsonb, + }) + + if (this.rowMode) { + const jsonColumns = sql.join( + query.selection.map((s) => { + return sql`${sql.raw(this.dialect.escapeString(s.key))}, ${ + s.selection ? sql`${jsonb}(${sql.identifier(s.key)})` : sql.identifier(s.key) + }` + }), + sql`, `, + ) + + query.sql = sql`select json_object(${jsonColumns}) as ${sql.identifier("r")} from (${query.sql}) as ${sql.identifier( + "t", + )}` + } + + return query + } + + private _toSQL(): { query: BuildRelationalQueryResult; builtQuery: Query } { + const query = this._getQuery() + + const builtQuery = this.dialect.sqlToQuery(query.sql) + + return { query, builtQuery } + } + + toSQL(): Query { + return this._toSQL().builtQuery + } + + execute(placeholderValues?: Record) { + return this.mode === "first" ? this._prepare().get(placeholderValues) : this._prepare().all(placeholderValues) + } +} + +applyEffectWrapper(SQLiteEffectRelationalQuery) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/raw.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/raw.ts new file mode 100644 index 0000000000..26e3a63d83 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/raw.ts @@ -0,0 +1,49 @@ +/* oxlint-disable */ +import type * as Effect from "effect/Effect" +import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind } from "drizzle-orm/entity" +import type { RunnableQuery } from "drizzle-orm/runnable-query" +import type { PreparedQuery } from "drizzle-orm/session" +import type { Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql" +import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect" + +type SQLiteEffectRawAction = "all" | "get" | "values" | "run" + +export interface SQLiteEffectRaw + extends Effect.Effect, + RunnableQuery, + SQLWrapper {} + +export class SQLiteEffectRaw + implements RunnableQuery, SQLWrapper, PreparedQuery +{ + static readonly [entityKind]: string = "SQLiteEffectRaw" + + declare readonly _: { + readonly dialect: "sqlite" + readonly result: TResult + } + + constructor( + public execute: () => Effect.Effect, + /** @internal */ + public getSQL: () => SQL, + private action: SQLiteEffectRawAction, + private dialect: SQLiteAsyncDialect, + private mapBatchResult: (result: unknown) => unknown, + ) {} + + getQuery(): Query & { method: SQLiteEffectRawAction } { + return { ...this.dialect.sqlToQuery(this.getSQL()), method: this.action } + } + + mapResult(result: unknown, isFromBatch?: boolean) { + return isFromBatch ? this.mapBatchResult(result) : result + } + + _prepare(): PreparedQuery { + return this + } +} + +applyEffectWrapper(SQLiteEffectRaw) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/select.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/select.ts new file mode 100644 index 0000000000..0cfde54331 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/select.ts @@ -0,0 +1,279 @@ +/* oxlint-disable */ +import type * as Effect from "effect/Effect" +import type { CacheConfig } from "drizzle-orm/cache/core/types" +import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind, is } from "drizzle-orm/entity" +import type { + BuildSubquerySelection, + GetSelectTableName, + GetSelectTableSelection, + JoinNullability, + SelectMode, + SelectResult, +} from "drizzle-orm/query-builders/select.types" +import { SQL } from "drizzle-orm/sql/sql" +import type { ColumnsSelection, SQLWrapper } from "drizzle-orm/sql/sql" +import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns" +import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect" +import { SQLiteSelectQueryBuilderBase } from "drizzle-orm/sqlite-core/query-builders/select" +import type { + CreateSQLiteSelectFromBuilderMode, + SelectedFields, + SQLiteSelectConfig, + SQLiteSelectHKTBase, +} from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base" +import { Subquery } from "drizzle-orm/subquery" +import { type Assume, getTableColumns } from "drizzle-orm/utils" +import { getViewSelectedFieldsRuntime, orderSelectedFields } from "../../internal/drizzle-utils" +import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session" + +export type SQLiteEffectSelectPrepare< + T extends AnySQLiteEffectSelect, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> = SQLiteEffectPreparedQuery< + { + type: "async" + run: T["_"]["runResult"] + all: T["_"]["result"] + get: T["_"]["result"][number] | undefined + values: any[][] + execute: T["_"]["result"] + }, + TEffectHKT +> + +export class SQLiteEffectSelectBuilder< + TSelection extends SelectedFields | undefined, + TRunResult, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, + TBuilderMode extends "db" | "qb" = "db", +> { + static readonly [entityKind]: string = "SQLiteEffectSelectBuilder" + + private fields: TSelection + private session: SQLiteEffectSession | undefined + private dialect: SQLiteDialect + private withList: Subquery[] | undefined + private distinct: boolean | undefined + + constructor(config: { + fields: TSelection + session: SQLiteEffectSession | undefined + dialect: SQLiteDialect + withList?: Subquery[] + distinct?: boolean + }) { + this.fields = config.fields + this.session = config.session + this.dialect = config.dialect + this.withList = config.withList + this.distinct = config.distinct + } + + from( + source: TFrom, + ): TBuilderMode extends "db" + ? SQLiteEffectSelectBase< + GetSelectTableName, + TRunResult, + TSelection extends undefined ? GetSelectTableSelection : TSelection, + TSelection extends undefined ? "single" : "partial", + GetSelectTableName extends string ? Record, "not-null"> : {}, + false, + never, + SelectResult< + TSelection extends undefined ? GetSelectTableSelection : TSelection, + TSelection extends undefined ? "single" : "partial", + GetSelectTableName extends string ? Record, "not-null"> : {} + >[], + BuildSubquerySelection< + TSelection extends undefined ? GetSelectTableSelection : TSelection, + GetSelectTableName extends string ? Record, "not-null"> : {} + >, + TEffectHKT + > + : CreateSQLiteSelectFromBuilderMode< + TBuilderMode, + GetSelectTableName, + "async", + TRunResult, + TSelection extends undefined ? GetSelectTableSelection : TSelection, + TSelection extends undefined ? "single" : "partial" + > { + const isPartialSelect = !!this.fields + + let fields: SelectedFields + if (this.fields) { + fields = this.fields + } else if (is(source, Subquery)) { + fields = Object.fromEntries( + Object.keys(source._.selectedFields).map((key) => [ + key, + source[key as unknown as keyof typeof source] as unknown as SelectedFields[string], + ]), + ) + } else if (is(source, SQLiteViewBase)) { + fields = getViewSelectedFieldsRuntime(source).selectedFields as SelectedFields + } else if (is(source, SQL)) { + fields = {} + } else { + fields = getTableColumns(source) + } + + return new SQLiteEffectSelectBase({ + table: source, + fields, + isPartialSelect, + session: this.session as any, + dialect: this.dialect, + withList: this.withList, + distinct: this.distinct, + }) as any + } +} + +export interface SQLiteEffectSelectHKT + extends SQLiteSelectHKTBase { + _type: SQLiteEffectSelectBase< + this["tableName"], + this["runResult"], + Assume, + this["selectMode"], + Assume>, + this["dynamic"], + this["excludedMethods"], + Assume, + Assume, + TEffectHKT + > +} + +export interface SQLiteEffectSelectBase< + TTableName extends string | undefined, + TRunResult, + TSelection extends ColumnsSelection, + TSelectMode extends SelectMode = "single", + TNullabilityMap extends Record = TTableName extends string + ? Record + : {}, + TDynamic extends boolean = false, + TExcludedMethods extends string = never, + TResult extends any[] = SelectResult[], + TSelectedFields extends ColumnsSelection = BuildSubquerySelection, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> extends SQLiteSelectQueryBuilderBase< + SQLiteEffectSelectHKT, + TTableName, + "async", + TRunResult, + TSelection, + TSelectMode, + TNullabilityMap, + TDynamic, + TExcludedMethods, + TResult, + TSelectedFields + >, + Effect.Effect {} + +export class SQLiteEffectSelectBase< + TTableName extends string | undefined, + TRunResult, + TSelection extends ColumnsSelection, + TSelectMode extends SelectMode = "single", + TNullabilityMap extends Record = TTableName extends string + ? Record + : {}, + TDynamic extends boolean = false, + TExcludedMethods extends string = never, + TResult extends any[] = SelectResult[], + TSelectedFields extends ColumnsSelection = BuildSubquerySelection, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, + > + extends SQLiteSelectQueryBuilderBase< + SQLiteEffectSelectHKT, + TTableName, + "async", + TRunResult, + TSelection, + TSelectMode, + TNullabilityMap, + TDynamic, + TExcludedMethods, + TResult, + TSelectedFields + > + implements SQLWrapper +{ + static override readonly [entityKind]: string = "SQLiteEffectSelect" + + private get effectConfig() { + return (this as unknown as { config: SQLiteSelectConfig }).config + } + + /** @internal */ + getSQL(): SQL { + return this.dialect.buildSelectQuery(this.effectConfig) + } + + /** @internal */ + _prepare(isOneTimeQuery = true): SQLiteEffectSelectPrepare { + if (!this.session) { + throw new Error("Cannot execute a query on a query builder. Please use a database instance instead.") + } + const session = this.session as unknown as SQLiteEffectSession + const query = session[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"]( + this.dialect.sqlToQuery(this.getSQL()), + orderSelectedFields(this.effectConfig.fields), + "all", + undefined, + { + type: "select", + tables: [...this.usedTables], + }, + this.cacheConfig, + ) + query.joinsNotNullableMap = this.joinsNotNullableMap + return query as ReturnType + } + + $withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) { + this.cacheConfig = + config === undefined + ? { config: {}, enabled: true, autoInvalidate: true } + : config === false + ? { enabled: false } + : { enabled: true, autoInvalidate: true, ...config } + return this + } + + prepare(): SQLiteEffectSelectPrepare { + return this._prepare(false) + } + + run: ReturnType["run"] = (placeholderValues) => { + return this._prepare().run(placeholderValues) + } + + all: ReturnType["all"] = (placeholderValues) => { + return this._prepare().all(placeholderValues) + } + + get: ReturnType["get"] = (placeholderValues) => { + return this._prepare().get(placeholderValues) + } + + values: ReturnType["values"] = (placeholderValues) => { + return this._prepare().values(placeholderValues) + } + + execute: ReturnType["execute"] = (placeholderValues) => { + return this._prepare().execute(placeholderValues) + } +} + +applyEffectWrapper(SQLiteEffectSelectBase) + +export type AnySQLiteEffectSelect = SQLiteEffectSelectBase diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts new file mode 100644 index 0000000000..15a56f2ca7 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts @@ -0,0 +1,490 @@ +/* oxlint-disable */ +import * as Cause from "effect/Cause" +import * as Effect from "effect/Effect" +import type { SqlError } from "effect/unstable/sql/SqlError" +import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect" +import { NoopCache, strategyFor } from "drizzle-orm/cache/core/cache" +import type { WithCacheConfig } from "drizzle-orm/cache/core/types" +import { MigratorInitError } from "drizzle-orm/effect-core/errors" +import { EffectDrizzleQueryError, EffectTransactionRollbackError } from "drizzle-orm/effect-core/errors" +import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger" +import type { QueryEffectHKTBase, QueryEffectKind } from "drizzle-orm/effect-core/query-effect" +import { entityKind, is } from "drizzle-orm/entity" +import type { MigrationConfig, MigrationMeta } from "drizzle-orm/migrator" +import { getMigrationsToRun } from "drizzle-orm/migrator.utils" +import type { + AnyRelations, + EmptyRelations, + RelationalQueryMapperConfig, + RelationalRowsMapper, +} from "drizzle-orm/relations" +import { makeJitRqbMapper } from "drizzle-orm/relations" +import type { PreparedQuery } from "drizzle-orm/session" +import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql" +import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect" +import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session" +import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite" +import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils" +import { mapResultRow } from "../../internal/drizzle-utils" +import { SQLiteEffectDatabase } from "./db" + +type MigrationConfigWithInit = MigrationConfig & { init?: boolean } + +type SQLiteEffectExecuteMethod = SQLiteExecuteMethod | "values" + +export class SQLiteEffectPreparedQuery< + T extends PreparedQueryConfig, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, + TIsRqbV2 extends boolean = false, +> implements PreparedQuery +{ + static readonly [entityKind]: string = "SQLiteEffectPreparedQuery" + + /** @internal */ + joinsNotNullableMap?: Record + private jitMapper?: RowsMapper | RelationalRowsMapper + private cacheConfig: WithCacheConfig | undefined + private effectExecuteMethod: SQLiteExecuteMethod + + constructor( + private executor: ( + params: unknown[], + executeMethod: SQLiteEffectExecuteMethod, + ) => Effect.Effect, + protected query: Query, + private logger: EffectLoggerShape, + private cache: EffectCacheShape, + private queryMetadata: + | { + type: "select" | "update" | "delete" | "insert" + tables: string[] + } + | undefined, + cacheConfig: WithCacheConfig | undefined, + private fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + private useJitMappers: boolean | undefined, + private customResultMapper?: ( + rows: TIsRqbV2 extends true ? Record[] : unknown[][], + mapColumnValue?: (value: unknown) => unknown, + ) => unknown, + private isRqbV2Query?: TIsRqbV2, + private rqbConfig?: RelationalQueryMapperConfig, + private isInTransaction: Effect.Effect = Effect.succeed(false), + ) { + this.effectExecuteMethod = executeMethod + this.cacheConfig = + cache.strategy() === "all" && cacheConfig === undefined ? { enabled: true, autoInvalidate: true } : cacheConfig + if (!this.cacheConfig?.enabled) { + this.cacheConfig = undefined + } + } + + run(placeholderValues?: Record): QueryEffectKind + run(placeholderValues?: Record): any { + return this.executeWithCache(placeholderValues, "run") + } + + all(placeholderValues?: Record): QueryEffectKind + all(placeholderValues?: Record): any { + if (this.isRqbV2Query) return this.allRqbV2(placeholderValues) + + if (!this.fields && !this.customResultMapper) { + return this.executeWithCache(placeholderValues, "all") + } + + return this.executeWithCache( + placeholderValues, + "values", + (rows) => this.mapAllResult(rows) as T["all"], + ) + } + + get(placeholderValues?: Record): QueryEffectKind + get(placeholderValues?: Record): any { + if (this.isRqbV2Query) return this.getRqbV2(placeholderValues) + + if (!this.fields && !this.customResultMapper) { + return this.executeWithCache(placeholderValues, "get") + } + + return this.executeWithCache( + placeholderValues, + "values", + (rows) => this.mapGetResult(rows) as T["get"], + ) + } + + values(placeholderValues?: Record): QueryEffectKind + values(placeholderValues?: Record): any { + return this.executeWithCache(placeholderValues, "values") + } + + execute(placeholderValues?: Record): QueryEffectKind + execute(placeholderValues?: Record): any { + return this[this.effectExecuteMethod](placeholderValues) as QueryEffectKind + } + + mapRunResult(result: unknown, _isFromBatch?: boolean): unknown { + return result + } + + mapAllResult(rows: unknown, isFromBatch?: boolean): unknown { + if (isFromBatch) { + rows = Array.isArray(rows) ? rows : [] + } + + if (!this.fields && !this.customResultMapper) { + return rows + } + + if (this.isRqbV2Query) { + return this.useJitMappers + ? (this.jitMapper = + (this.jitMapper as RelationalRowsMapper) ?? makeJitRqbMapper(this.rqbConfig!))( + rows as Record[], + ) + : (this.customResultMapper as (rows: Record[]) => unknown)(rows as Record[]) + } + + if (this.customResultMapper) { + return (this.customResultMapper as (rows: unknown[][]) => unknown)(rows as unknown[][]) as T["all"] + } + + return this.useJitMappers + ? (this.jitMapper = + (this.jitMapper as RowsMapper) ?? + makeJitQueryMapper(this.fields!, this.joinsNotNullableMap))(rows as unknown[][]) + : (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap)) + } + + mapGetResult(rows: unknown, isFromBatch?: boolean): unknown { + if (isFromBatch) { + rows = Array.isArray(rows) ? rows : [] + } + + if (!this.fields && !this.customResultMapper) { + return Array.isArray(rows) ? rows[0] : rows + } + + const row = Array.isArray(rows) ? rows[0] : rows + if (!row) return undefined + + if (this.isRqbV2Query) { + return this.useJitMappers + ? (this.jitMapper = + (this.jitMapper as RelationalRowsMapper) ?? makeJitRqbMapper(this.rqbConfig!))([ + row as Record, + ]) + : (this.customResultMapper as (rows: Record[]) => unknown)([row as Record]) + } + + if (this.customResultMapper) { + return (this.customResultMapper as (rows: unknown[][]) => unknown)([row as unknown[]]) as T["get"] + } + + return this.useJitMappers + ? (this.jitMapper = + (this.jitMapper as RowsMapper) ?? + makeJitQueryMapper(this.fields!, this.joinsNotNullableMap))([row as unknown[]])[0] + : mapResultRow(this.fields!, row as unknown[], this.joinsNotNullableMap) + } + + private allRqbV2(placeholderValues?: Record) { + return this.executeWithCache( + placeholderValues, + "all", + (rows) => this.mapAllResult(rows) as T["all"], + ) + } + + private getRqbV2(placeholderValues?: Record) { + return this.executeWithCache(placeholderValues, "get", (row) => + row === undefined ? undefined : (this.mapGetResult(row) as T["get"]), + ) + } + + private executeWithCache( + placeholderValues: Record | undefined, + executeMethod: SQLiteEffectExecuteMethod, + mapResult?: (result: A) => B, + ) { + return Effect.gen({ self: this }, function* () { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}) + + yield* this.logger.logQuery(this.query.sql, params) + + return yield* this.queryWithCache( + this.query.sql, + params, + Effect.suspend(() => this.executor(params, executeMethod) as Effect.Effect), + mapResult, + ) + }) + } + + private mapCachedResult(result: A, mapResult: ((result: A) => B) | undefined) { + if (!mapResult) return Effect.succeed(result as unknown as B) + return Effect.try({ + try: () => mapResult(result), + catch: (cause) => cause, + }) + } + + private queryWithCache( + queryString: string, + params: unknown[], + query: Effect.Effect, + mapResult?: (result: A) => B, + ) { + return Effect.gen({ self: this }, function* () { + if (this.queryMetadata?.type === "select" && this.cacheConfig?.enabled && (yield* this.isInTransaction)) { + return yield* this.mapCachedResult(yield* query, mapResult) + } + + const cacheStrat: Awaited> = !is(this.cache.cache, NoopCache) + ? yield* Effect.tryPromise(() => strategyFor(queryString, params, this.queryMetadata, this.cacheConfig)) + : { type: "skip" as const } + + if (cacheStrat.type === "skip") { + return yield* this.mapCachedResult(yield* query, mapResult) + } + + if (cacheStrat.type === "invalidate") { + const result = yield* query + yield* this.cache.onMutate({ tables: cacheStrat.tables }) + return yield* this.mapCachedResult(result, mapResult) + } + + if (cacheStrat.type === "try") { + if (yield* this.isInTransaction) { + return yield* this.mapCachedResult(yield* query, mapResult) + } + + const { tables, key, isTag, autoInvalidate, config } = cacheStrat + const fromCache: any[] | undefined = yield* this.cache.get(key, tables, isTag, autoInvalidate) + + if (typeof fromCache !== "undefined") { + return yield* this.mapCachedResult(fromCache as unknown as A, mapResult) + } + + const result = yield* query + + yield* this.cache.put(key, result, autoInvalidate ? tables : [], isTag, config) + + return yield* this.mapCachedResult(result, mapResult) + } + + assertUnreachable(cacheStrat) + }).pipe( + Effect.catch((e) => { + return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })) + }), + ) + } + + getQuery(): Query { + return this.query + } + + mapResult(response: unknown, isFromBatch?: boolean) { + switch (this.effectExecuteMethod) { + case "run": { + return this.mapRunResult(response, isFromBatch) + } + case "all": { + return this.mapAllResult(response, isFromBatch) + } + case "get": { + return this.mapGetResult(response, isFromBatch) + } + } + } +} + +export abstract class SQLiteEffectSession< + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, + TRunResult = unknown, + TRelations extends AnyRelations = EmptyRelations, +> { + static readonly [entityKind]: string = "SQLiteEffectSession" + + constructor(readonly dialect: SQLiteAsyncDialect) {} + + abstract prepareQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown, + queryMetadata?: { + type: "select" | "update" | "delete" | "insert" + tables: string[] + }, + cacheConfig?: WithCacheConfig, + ): SQLiteEffectPreparedQuery + + prepareOneTimeQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown, + queryMetadata?: { + type: "select" | "update" | "delete" | "insert" + tables: string[] + }, + cacheConfig?: WithCacheConfig, + ): SQLiteEffectPreparedQuery { + return this.prepareQuery(query, fields, executeMethod, customResultMapper, queryMetadata, cacheConfig) + } + + abstract prepareRelationalQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + customResultMapper: (rows: Record[], mapColumnValue?: (value: unknown) => unknown) => unknown, + config: RelationalQueryMapperConfig, + ): SQLiteEffectPreparedQuery + + prepareOneTimeRelationalQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + customResultMapper: (rows: Record[], mapColumnValue?: (value: unknown) => unknown) => unknown, + config: RelationalQueryMapperConfig, + ): SQLiteEffectPreparedQuery { + return this.prepareRelationalQuery(query, fields, executeMethod, customResultMapper, config) + } + + run(query: SQL): QueryEffectKind + run(query: SQL): any { + return this.prepareQuery( + this.dialect.sqlToQuery(query), + undefined, + "run", + ).run() + } + + all(query: SQL): QueryEffectKind + all(query: SQL): any { + return this.prepareQuery( + this.dialect.sqlToQuery(query), + undefined, + "all", + ).all() + } + + get(query: SQL): QueryEffectKind + get(query: SQL): any { + return this.prepareQuery( + this.dialect.sqlToQuery(query), + undefined, + "get", + ).get() + } + + values(query: SQL): QueryEffectKind + values(query: SQL): any { + return this.prepareQuery( + this.dialect.sqlToQuery(query), + undefined, + "all", + ).values() + } + + count(query: SQL): QueryEffectKind + count(query: SQL): any { + return this.values<[number]>(query).pipe(Effect.map((result) => result[0]?.[0] ?? 0)) + } + + abstract transaction( + transaction: (tx: SQLiteEffectTransaction) => Effect.Effect, + config?: SQLiteTransactionConfig, + ): Effect.Effect +} + +export abstract class SQLiteEffectTransaction< + TEffectHKT extends QueryEffectHKTBase, + TRunResult, + TRelations extends AnyRelations = EmptyRelations, +> extends SQLiteEffectDatabase { + static override readonly [entityKind]: string = "SQLiteEffectTransaction" + + constructor( + dialect: SQLiteAsyncDialect, + session: SQLiteEffectSession, + protected relations: TRelations, + ) { + super(dialect, session, relations) + } + + rollback() { + return new EffectTransactionRollbackError() + } +} + +export const migrate = Effect.fn("migrate")(function* ( + migrations: MigrationMeta[], + session: SQLiteEffectSession, + config: string | MigrationConfigWithInit, +) { + const migrationsTable = + typeof config === "string" ? "__drizzle_migrations" : (config.migrationsTable ?? "__drizzle_migrations") + + const { newDb } = yield* upgradeIfNeeded(migrationsTable, session, migrations) + + if (newDb) { + yield* session.run(sql` + CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} ( + id INTEGER PRIMARY KEY, + hash text NOT NULL, + created_at numeric, + name text, + applied_at TEXT + ) + `) + } + + const dbMigrations = yield* session.all<{ id: number; hash: string; created_at: string; name: string | null }>( + sql`SELECT id, hash, created_at, name FROM ${sql.identifier(migrationsTable)}`, + ) + + if (typeof config === "object" && config.init) { + if (dbMigrations.length) { + return yield* new MigratorInitError({ exitCode: "databaseMigrations" }) + } + + if (migrations.length > 1) { + return yield* new MigratorInitError({ exitCode: "localMigrations" }) + } + + const [migration] = migrations + if (!migration) return + + yield* session.run( + sql`insert into ${sql.identifier( + migrationsTable, + )} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`, + ) + + return + } + + const migrationsToRun = getMigrationsToRun({ localMigrations: migrations, dbMigrations }) + if (migrationsToRun.length === 0) return + + yield* session.transaction((tx) => + Effect.gen(function* () { + for (const migration of migrationsToRun) { + for (const stmt of migration.sql) { + yield* tx.run(sql.raw(stmt)) + } + yield* tx.run( + sql`insert into ${sql.identifier( + migrationsTable, + )} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`, + ) + } + }), + ) +}) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/update.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/update.ts new file mode 100644 index 0000000000..369e08e878 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/update.ts @@ -0,0 +1,402 @@ +/* oxlint-disable */ +import type * as Effect from "effect/Effect" +import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import { entityKind, is } from "drizzle-orm/entity" +import type { SelectResultFields } from "drizzle-orm/query-builders/select.types" +import type { RunnableQuery } from "drizzle-orm/runnable-query" +import { SelectionProxyHandler } from "drizzle-orm/selection-proxy" +import type { Placeholder, Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql" +import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect" +import type { SelectedFields, SQLiteSelectJoinConfig } from "drizzle-orm/sqlite-core/query-builders/select.types" +import type { SQLiteUpdateConfig, SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update" +import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session" +import { SQLiteTable } from "drizzle-orm/sqlite-core/table" +import { extractUsedTable } from "drizzle-orm/sqlite-core/utils" +import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base" +import { Subquery } from "drizzle-orm/subquery" +import { type DrizzleTypeError, type UpdateSet, type ValueOrArray } from "drizzle-orm/utils" +import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common" +import { + getTableColumnsRuntime, + getTableLikeName, + getViewSelectedFieldsRuntime, + mapUpdateSet, + orderSelectedFields, +} from "../../internal/drizzle-utils" +import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session" + +export type SQLiteEffectUpdateWithout< + T extends AnySQLiteEffectUpdate, + TDynamic extends boolean, + K extends keyof T & string, +> = TDynamic extends true + ? T + : Omit< + SQLiteEffectUpdateBase< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["from"], + T["_"]["returning"], + TDynamic, + T["_"]["excludedMethods"] | K, + T["_"]["effectHKT"] + >, + T["_"]["excludedMethods"] | K + > + +export type SQLiteEffectUpdateWithJoins< + T extends AnySQLiteEffectUpdate, + TDynamic extends boolean, + TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL, +> = TDynamic extends true + ? T + : Omit< + SQLiteEffectUpdateBase< + T["_"]["table"], + T["_"]["runResult"], + TFrom, + T["_"]["returning"], + TDynamic, + Exclude, + T["_"]["effectHKT"] + >, + Exclude + > + +export type SQLiteEffectUpdateReturningAll< + T extends AnySQLiteEffectUpdate, + TDynamic extends boolean, +> = SQLiteEffectUpdateWithout< + SQLiteEffectUpdateBase< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["from"], + T["_"]["table"]["$inferSelect"], + TDynamic, + T["_"]["excludedMethods"], + T["_"]["effectHKT"] + >, + TDynamic, + "returning" +> + +export type SQLiteEffectUpdateReturning< + T extends AnySQLiteEffectUpdate, + TDynamic extends boolean, + TSelectedFields extends SelectedFields, +> = SQLiteEffectUpdateWithout< + SQLiteEffectUpdateBase< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["from"], + SelectResultFields, + TDynamic, + T["_"]["excludedMethods"], + T["_"]["effectHKT"] + >, + TDynamic, + "returning" +> + +export type SQLiteEffectUpdateExecute = T["_"]["returning"] extends undefined + ? T["_"]["runResult"] + : T["_"]["returning"][] + +export type SQLiteEffectUpdatePrepare< + T extends AnySQLiteEffectUpdate, + TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"], +> = SQLiteEffectPreparedQuery< + PreparedQueryConfig & { + run: T["_"]["runResult"] + all: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".all() cannot be used without .returning()"> + : T["_"]["returning"][] + get: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".get() cannot be used without .returning()"> + : T["_"]["returning"] + values: T["_"]["returning"] extends undefined + ? DrizzleTypeError<".values() cannot be used without .returning()"> + : any[][] + execute: SQLiteEffectUpdateExecute + }, + TEffectHKT +> + +export type SQLiteEffectUpdateDynamic = SQLiteEffectUpdate< + T["_"]["table"], + T["_"]["runResult"], + T["_"]["from"], + T["_"]["returning"], + T["_"]["effectHKT"] +> + +export type SQLiteEffectUpdate< + TTable extends SQLiteTable = SQLiteTable, + TRunResult = unknown, + TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined, + TReturning extends Record | undefined = Record | undefined, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> = SQLiteEffectUpdateBase + +export type AnySQLiteEffectUpdate = SQLiteEffectUpdateBase + +export type SQLiteEffectUpdateJoinFn = < + TJoinedTable extends SQLiteTable | Subquery | SQLiteViewBase | SQL, +>( + table: TJoinedTable, + on: + | (( + updateTable: T["_"]["table"]["_"]["columns"], + from: T["_"]["from"] extends SQLiteTable + ? T["_"]["from"]["_"]["columns"] + : T["_"]["from"] extends Subquery | SQLiteViewBase + ? T["_"]["from"]["_"]["selectedFields"] + : never, + ) => SQL | undefined) + | SQL + | undefined, +) => T + +export class SQLiteEffectUpdateBuilder< + TTable extends SQLiteTable, + TRunResult, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> { + static readonly [entityKind]: string = "SQLiteEffectUpdateBuilder" + + declare readonly _: { + readonly table: TTable + } + + constructor( + protected table: TTable, + protected session: SQLiteEffectSession, + protected dialect: SQLiteDialect, + private withList?: Subquery[], + ) {} + + set( + values: SQLiteUpdateSetSource, + ): SQLiteEffectUpdateWithout< + SQLiteEffectUpdateBase, + false, + "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin" + > { + return new SQLiteEffectUpdateBase( + this.table, + mapUpdateSet(this.table, values), + this.session, + this.dialect, + this.withList, + ) as any + } +} + +export interface SQLiteEffectUpdateBase< + TTable extends SQLiteTable = SQLiteTable, + TRunResult = unknown, + TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined, + TReturning = undefined, + TDynamic extends boolean = false, + _TExcludedMethods extends string = never, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, +> extends SQLWrapper, + RunnableQuery, + Effect.Effect< + TReturning extends undefined ? TRunResult : TReturning[], + TEffectHKT["error"], + TEffectHKT["context"] + > { + readonly _: { + readonly dialect: "sqlite" + readonly table: TTable + readonly resultType: "async" + readonly runResult: TRunResult + readonly from: TFrom + readonly returning: TReturning + readonly dynamic: TDynamic + readonly excludedMethods: _TExcludedMethods + readonly result: TReturning extends undefined ? TRunResult : TReturning[] + readonly effectHKT: TEffectHKT + } +} + +export class SQLiteEffectUpdateBase< + TTable extends SQLiteTable = SQLiteTable, + TRunResult = unknown, + TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined, + TReturning = undefined, + TDynamic extends boolean = false, + _TExcludedMethods extends string = never, + TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase, + > + implements RunnableQuery, SQLWrapper +{ + static readonly [entityKind]: string = "SQLiteEffectUpdate" + + /** @internal */ + config: SQLiteUpdateConfig + + constructor( + table: TTable, + set: UpdateSet, + private effectSession: SQLiteEffectSession, + private effectDialect: SQLiteDialect, + withList?: Subquery[], + ) { + this.config = { set, table, withList, joins: [] } + } + + from( + source: TFrom, + ): SQLiteEffectUpdateWithJoins { + this.config.from = source + return this as any + } + + private createJoin( + joinType: TJoinType, + ): SQLiteEffectUpdateJoinFn { + return (( + table: SQLiteTable | Subquery | SQLiteViewBase | SQL, + on: ((updateTable: TTable, from: TFrom) => SQL | undefined) | SQL | undefined, + ) => { + const tableName = getTableLikeName(table) + + if (typeof tableName === "string" && this.config.joins.some((join) => join.alias === tableName)) { + throw new Error(`Alias "${tableName}" is already used in this query`) + } + + if (typeof on === "function") { + const from = this.config.from + ? is(table, SQLiteTable) + ? getTableColumnsRuntime(table) + : is(table, Subquery) + ? table._.selectedFields + : is(table, SQLiteViewBase) + ? getViewSelectedFieldsRuntime(table).selectedFields + : undefined + : undefined + on = on( + new Proxy( + this.config.table._.columns, + new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }), + ) as any, + from && + (new Proxy(from, new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" })) as any), + ) + } + + this.config.joins.push({ on, table, joinType, alias: tableName }) + + return this as any + }) as any + } + + leftJoin = this.createJoin("left") + + rightJoin = this.createJoin("right") + + innerJoin = this.createJoin("inner") + + fullJoin = this.createJoin("full") + + where(where: SQL | undefined): SQLiteEffectUpdateWithout { + this.config.where = where + return this as any + } + + orderBy( + builder: (updateTable: TTable) => ValueOrArray, + ): SQLiteEffectUpdateWithout + orderBy(...columns: (SQLiteColumn | SQL | SQL.Aliased)[]): SQLiteEffectUpdateWithout + orderBy( + ...columns: + | [(updateTable: TTable) => ValueOrArray] + | (SQLiteColumn | SQL | SQL.Aliased)[] + ): SQLiteEffectUpdateWithout { + if (typeof columns[0] === "function") { + const orderBy = columns[0]( + new Proxy( + getTableColumnsRuntime(this.config.table), + new SelectionProxyHandler({ sqlAliasedBehavior: "alias", sqlBehavior: "sql" }), + ) as any, + ) + + this.config.orderBy = Array.isArray(orderBy) ? orderBy : [orderBy] + return this as any + } + + this.config.orderBy = columns as (SQLiteColumn | SQL | SQL.Aliased)[] + return this as any + } + + limit(limit: number | Placeholder): SQLiteEffectUpdateWithout { + this.config.limit = limit + return this as any + } + + returning(): SQLiteEffectUpdateReturningAll + returning( + fields: TSelectedFields, + ): SQLiteEffectUpdateReturning + returning( + fields: SelectedFields = getTableColumnsRuntime(this.config.table), + ): SQLiteEffectUpdateWithout { + this.config.returning = orderSelectedFields(fields) + return this as any + } + + /** @internal */ + getSQL(): SQL { + return this.effectDialect.buildUpdateQuery(this.config) + } + + toSQL(): Query { + return this.effectDialect.sqlToQuery(this.getSQL()) + } + + /** @internal */ + _prepare(isOneTimeQuery = true): SQLiteEffectUpdatePrepare { + return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"]( + this.effectDialect.sqlToQuery(this.getSQL()), + this.config.returning, + this.config.returning ? "all" : "run", + undefined, + { + type: "update", + tables: extractUsedTable(this.config.table), + }, + ) as SQLiteEffectUpdatePrepare + } + + prepare(): SQLiteEffectUpdatePrepare { + return this._prepare(false) + } + + run: ReturnType["run"] = (placeholderValues) => { + return this._prepare().run(placeholderValues) + } + + all: ReturnType["all"] = (placeholderValues) => { + return this._prepare().all(placeholderValues) + } + + get: ReturnType["get"] = (placeholderValues) => { + return this._prepare().get(placeholderValues) + } + + values: ReturnType["values"] = (placeholderValues) => { + return this._prepare().values(placeholderValues) + } + + execute: ReturnType["execute"] = (placeholderValues) => { + return this._prepare().execute(placeholderValues) + } + + $dynamic(): SQLiteEffectUpdateDynamic { + return this as any + } +} + +applyEffectWrapper(SQLiteEffectUpdateBase) diff --git a/packages/effect-drizzle-sqlite/src/up-migrations/effect-sqlite.ts b/packages/effect-drizzle-sqlite/src/up-migrations/effect-sqlite.ts new file mode 100644 index 0000000000..3418752271 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/up-migrations/effect-sqlite.ts @@ -0,0 +1,102 @@ +/* oxlint-disable */ +import * as Effect from "effect/Effect" +import type { SqlError } from "effect/unstable/sql/SqlError" +import { EffectDrizzleError } from "drizzle-orm/effect-core/errors" +import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect" +import type { MigrationMeta } from "drizzle-orm/migrator" +import { sql } from "drizzle-orm/sql/sql" +import type { SQLiteEffectSession } from "../sqlite-core/effect/session" +import { + buildSQLiteMigrationBackfillStatements, + prepareSQLiteMigrationBackfill, + type SQLiteMigrationTableRow, +} from "./sqlite" +import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils" + +const migrationUpgradeError = (cause: unknown) => + new EffectDrizzleError({ + message: + typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string" + ? cause.message + : String(cause), + cause, + }) + +export const upgradeIfNeeded: ( + migrationsTable: string, + session: SQLiteEffectSession, + localMigrations: MigrationMeta[], +) => Effect.Effect = + Effect.fn("upgradeIfNeeded")(function* ( + migrationsTable: string, + session: SQLiteEffectSession, + localMigrations: MigrationMeta[], + ) { + const tableExists = yield* session.all( + sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`, + ) + + if (tableExists.length === 0) { + return { newDb: true } + } + + const rows = yield* session.all<{ column_name: string }>( + sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`, + ) + + const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name)) + + for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) { + const upgradeFn = upgradeFunctions[v] + if (!upgradeFn) { + return yield* new EffectDrizzleError({ + message: `No upgrade path from migration table version ${v} to ${v + 1}`, + cause: { version: v }, + }) + } + yield* upgradeFn(migrationsTable, session, localMigrations) + } + + return { newDb: false } + }) + +const upgradeFunctions: Record< + number, + ( + migrationsTable: string, + session: SQLiteEffectSession, + localMigrations: MigrationMeta[], + ) => Effect.Effect +> = { + 0: upgradeFromV0, +} + +function upgradeFromV0( + migrationsTable: string, + session: SQLiteEffectSession, + localMigrations: MigrationMeta[], +): Effect.Effect { + return Effect.gen(function* () { + const table = sql`${sql.identifier(migrationsTable)}` + + const dbRows = yield* session.all( + sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`, + ) + const statements = yield* Effect.try({ + try: () => + buildSQLiteMigrationBackfillStatements( + migrationsTable, + prepareSQLiteMigrationBackfill(dbRows, localMigrations), + ), + catch: migrationUpgradeError, + }) + + yield* session.transaction((tx) => + Effect.gen(function* () { + for (const statement of statements) { + yield* tx.run(statement) + } + }), + ) + }) +} diff --git a/packages/effect-drizzle-sqlite/src/up-migrations/sqlite.ts b/packages/effect-drizzle-sqlite/src/up-migrations/sqlite.ts new file mode 100644 index 0000000000..2887c66bed --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/up-migrations/sqlite.ts @@ -0,0 +1,253 @@ +/* oxlint-disable */ +import type { TablesRelationalConfig } from "drizzle-orm/_relations" +import type { MigrationMeta } from "drizzle-orm/migrator" +import type { AnyRelations } from "drizzle-orm/relations" +import { type SQL, sql } from "drizzle-orm/sql/sql" +import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core" +import type { SQLiteSession } from "drizzle-orm/sqlite-core/session" +import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils" + +/** @internal */ +export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number } + +type AsyncSQLiteDatabaseWithSession = BaseSQLiteDatabase<"async", unknown, Record> & { + session: { + all(query: SQL): Promise + } + transaction(transaction: (tx: { run(query: SQL): Promise }) => Promise): Promise +} + +type SQLiteMigrationBackfillEntry = { + name: string + selector: + | { column: "id"; value: number } + | { column: "created_at"; value: number } + | { column: "hash"; value: string } +} + +function unmatchedMigrationError(unmatched: SQLiteMigrationTableRow[]) { + return new Error( + `While upgrading your database migrations table we found ${unmatched.length} (${unmatched + .map((it) => `[id: ${it.id}, created_at: ${it.created_at}]`) + .join( + ", ", + )}) migrations in the database that do not match any local migration. This means that some migrations were applied to the database but are missing from the local environment`, + ) +} + +/** @internal */ +export function prepareSQLiteMigrationBackfill( + dbRows: SQLiteMigrationTableRow[], + localMigrations: MigrationMeta[], +): SQLiteMigrationBackfillEntry[] { + const sortedLocalMigrations = [...localMigrations].sort((a, b) => + a.folderMillis !== b.folderMillis ? a.folderMillis - b.folderMillis : (a.name ?? "").localeCompare(b.name ?? ""), + ) + const byMillis = new Map() + const byHash = new Map() + for (const migration of sortedLocalMigrations) { + if (!byMillis.has(migration.folderMillis)) { + byMillis.set(migration.folderMillis, []) + } + byMillis.get(migration.folderMillis)!.push(migration) + byHash.set(migration.hash, migration) + } + + const toApply: SQLiteMigrationBackfillEntry[] = [] + const unmatched: SQLiteMigrationTableRow[] = [] + + for (const dbRow of dbRows) { + const stringified = String(dbRow.created_at) + const millis = Number(stringified.substring(0, stringified.length - 3) + "000") + const candidates = byMillis.get(millis) + + const matchedByMillis = candidates?.length === 1 ? candidates[0] : undefined + const matchedByCandidateHash = + candidates && candidates.length > 1 + ? candidates.find((candidate) => candidate.hash && dbRow.hash && candidate.hash === dbRow.hash) + : undefined + const matchedByHash = matchedByMillis || matchedByCandidateHash ? undefined : byHash.get(dbRow.hash) + const matched = matchedByMillis ?? matchedByCandidateHash ?? matchedByHash + + if (matched) { + toApply.push({ + name: matched.name, + selector: + dbRow.id !== null + ? { column: "id", value: dbRow.id } + : matchedByMillis + ? { column: "created_at", value: dbRow.created_at } + : { column: "hash", value: dbRow.hash }, + }) + continue + } + + unmatched.push(dbRow) + } + + if (unmatched.length > 0) { + throw unmatchedMigrationError(unmatched) + } + + return toApply +} + +/** @internal */ +export function buildSQLiteMigrationBackfillStatements( + migrationsTable: string, + backfillEntries: SQLiteMigrationBackfillEntry[], +) { + const table = sql`${sql.identifier(migrationsTable)}` + const statements: SQL[] = [ + sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("name")} text`, + sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("applied_at")} TEXT`, + ] + + for (const backfillEntry of backfillEntries) { + const updateQuery = sql`UPDATE ${table} SET ${sql.identifier("name")} = ${backfillEntry.name}, ${sql.identifier( + "applied_at", + )} = NULL WHERE` + + updateQuery.append(sql` ${sql.identifier(backfillEntry.selector.column)} = ${backfillEntry.selector.value}`) + + statements.push(updateQuery) + } + + return statements +} + +/** + * Detects the current version of the migrations table schema and upgrades it if needed. + * + * Version 0: Original schema (id, hash, created_at) + * Version 1: Extended schema (id, hash, created_at, name, applied_at) + */ +export function upgradeSyncIfNeeded( + migrationsTable: string, + session: SQLiteSession<"sync", unknown, Record, AnyRelations, TablesRelationalConfig>, + localMigrations: MigrationMeta[], +): UpgradeResult { + const tableExists = session.all(sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`) + + if (tableExists.length === 0) { + return { newDb: true } + } + + // Table exists, check table shape + const rows = session.all<{ column_name: string }>( + sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`, + ) + + const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name)) + + for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) { + const upgradeFn = upgradeSyncFunctions[v] + if (!upgradeFn) { + throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`) + } + upgradeFn(migrationsTable, session, localMigrations) + } + + return { newDb: false } +} + +const upgradeSyncFunctions: Record< + number, + ( + migrationsTable: string, + session: SQLiteSession<"sync", unknown, Record, AnyRelations, TablesRelationalConfig>, + localMigrations: MigrationMeta[], + ) => void +> = { + /** + * Upgrade from version 0 to version 1: + * 1. Read all existing DB migrations + * 2. Sort localMigrations ASC by millis and if the same - sort by name + * 3. Match each DB row to a local migration + * If multiple migrations share the same second, use hash matching as a tiebreaker + * Not implemented for now -> If hash matching fails, fall back to serial id ordering + * 5. Create extra column and backfill names for matched migrations + */ + 0: (migrationsTable, session, localMigrations) => { + const table = sql`${sql.identifier(migrationsTable)}` + const dbRows = session.all(sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`) + const statements = buildSQLiteMigrationBackfillStatements( + migrationsTable, + prepareSQLiteMigrationBackfill(dbRows, localMigrations), + ) + + session.transaction((tx) => { + for (const statement of statements) { + tx.run(statement) + } + }) + }, +} + +/** + * Detects the current version of the migrations table schema and upgrades it if needed. + * + * Version 0: Original schema (id, hash, created_at) + * Version 1: Extended schema (id, hash, created_at, name, applied_at) + */ +export async function upgradeAsyncIfNeeded( + migrationsTable: string, + db: AsyncSQLiteDatabaseWithSession, + localMigrations: MigrationMeta[], +): Promise { + // Check if the table exists at all + const tableExists = await db.session.all( + sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`, + ) + + if (tableExists.length === 0) { + return { newDb: true } + } + + const rows = await db.session.all<{ column_name: string }>( + sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`, + ) + + const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name)) + + for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) { + const upgradeFn = upgradeAsyncFunctions[v] + if (!upgradeFn) { + throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`) + } + await upgradeFn(migrationsTable, db, localMigrations) + } + + return { newDb: false } +} + +const upgradeAsyncFunctions: Record< + number, + (migrationsTable: string, db: AsyncSQLiteDatabaseWithSession, localMigrations: MigrationMeta[]) => Promise +> = { + /** + * Upgrade from version 0 to version 1: + * 1. Read all existing DB migrations + * 2. Sort localMigrations ASC by millis and if the same - sort by name + * 3. Match each DB row to a local migration + * If multiple migrations share the same second, use hash matching as a tiebreaker + * Not implemented for now -> If hash matching fails, fall back to serial id ordering + * 5. Create extra column and backfill names for matched migrations + */ + 0: async (migrationsTable, db, localMigrations) => { + const table = sql`${sql.identifier(migrationsTable)}` + const dbRows = await db.session.all( + sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`, + ) + const statements = buildSQLiteMigrationBackfillStatements( + migrationsTable, + prepareSQLiteMigrationBackfill(dbRows, localMigrations), + ) + + await db.transaction(async (tx) => { + for (const statement of statements) { + await tx.run(statement) + } + }) + }, +} diff --git a/packages/effect-drizzle-sqlite/src/up-migrations/utils.ts b/packages/effect-drizzle-sqlite/src/up-migrations/utils.ts new file mode 100644 index 0000000000..cc71213ac4 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/up-migrations/utils.ts @@ -0,0 +1,45 @@ +/* oxlint-disable */ +export interface UpgradeResult { + newDb: boolean +} + +export const MIGRATIONS_TABLE_VERSIONS = { + sqlite: 1, + pg: 1, + effect: 1, + mysql: 1, + mssql: 1, + cockroach: 1, + singlestore: 1, +} as const + +export const GET_VERSION_FOR = { + mysql: (columns: string[]): number => { + if (columns.includes("name")) return 1 + return 0 + }, + pg: (columns: string[]): number => { + if (columns.includes("name")) return 1 + return 0 + }, + effect: (columns: string[]): number => { + if (columns.includes("name")) return 1 + return 0 + }, + mssql: (columns: string[]): number => { + if (columns.includes("name")) return 1 + return 0 + }, + cockroach: (columns: string[]): number => { + if (columns.includes("name")) return 1 + return 0 + }, + singlestore: (columns: string[]): number => { + if (columns.includes("name")) return 1 + return 0 + }, + sqlite: (columns: string[]): number => { + if (columns.includes("name")) return 1 + return 0 + }, +} as const diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts new file mode 100644 index 0000000000..69e6ebed2e --- /dev/null +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -0,0 +1,139 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { expect, test } from "bun:test" +import { SqliteClient } from "@effect/sql-sqlite-bun" +import { eq, sql } from "drizzle-orm" +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Effect } from "effect" +import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" +import { EffectDrizzleSqlite } from "../src" + +const users = sqliteTable("users", { + id: integer().primaryKey({ autoIncrement: true }), + name: text().notNull(), +}) + +const run = (effect: Effect.Effect) => + Effect.runPromise( + effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), + ) + +const makeDb = Effect.gen(function* () { + const db = yield* EffectDrizzleSqlite.makeWithDefaults() + yield* db.run(sql`create table users (id integer primary key autoincrement, name text not null)`) + return db +}) + +const createMigrationsFolder = async () => { + const migrationsFolder = await mkdtemp(join(tmpdir(), "effect-drizzle-sqlite-")) + await mkdir(join(migrationsFolder, "20240101000000_create_migrated_users"), { recursive: true }) + await Bun.write( + join(migrationsFolder, "20240101000000_create_migrated_users", "migration.sql"), + "create table migrated_users (id integer primary key autoincrement, name text not null);", + ) + return migrationsFolder +} + +test("selects rows through Effect-yieldable query builders", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.insert(users).values({ name: "Ada" }) + + expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }]) + expect(yield* db.select({ id: users.id }).from(users).where(eq(users.name, "Ada")).get()).toEqual({ id: 1 }) + }), + ) +}) + +test("commits successful transactions", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + + yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }), { behavior: "immediate" }) + + expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Grace" }]) + }), + ) +}) + +test("rolls back failed transactions", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + + yield* db + .transaction((tx) => + tx + .insert(users) + .values({ name: "Linus" }) + .pipe(Effect.andThen(Effect.fail("boom"))), + ) + .pipe(Effect.ignore) + + expect(yield* db.select().from(users)).toEqual([]) + }), + ) +}) + +test("rolls back explicit transaction rollback", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + + yield* db + .transaction((tx) => + tx + .insert(users) + .values({ name: "Barbara" }) + .pipe(Effect.andThen(Effect.fail(tx.rollback()))), + ) + .pipe(Effect.ignore) + + expect(yield* db.select().from(users)).toEqual([]) + }), + ) +}) + +test("supports returning and rejects empty update sets", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + + const inserted = yield* db.insert(users).values({ name: "Ada" }).returning({ id: users.id, name: users.name }) + expect(inserted).toEqual([{ id: 1, name: "Ada" }]) + + const updated = yield* db.update(users).set({ name: "Grace" }).where(eq(users.id, 1)).returning() + expect(updated).toEqual([{ id: 1, name: "Grace" }]) + + const deleted = yield* db.delete(users).where(eq(users.id, 1)).returning({ id: users.id }) + expect(deleted).toEqual([{ id: 1 }]) + + expect(() => db.update(users).set({ name: undefined })).toThrow("No values to set") + }), + ) +}) + +test("runs migrations once and records migration metadata", async () => { + const migrationsFolder = await createMigrationsFolder() + try { + await run( + Effect.gen(function* () { + const db = yield* EffectDrizzleSqlite.makeWithDefaults() + + yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder }) + yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder }) + yield* db.run(sql`insert into migrated_users (name) values ('Margaret')`) + + expect(yield* db.all<{ name: string }>(sql`select name from migrated_users`)).toEqual([{ name: "Margaret" }]) + expect(yield* db.all<{ name: string | null }>(sql`select name from __drizzle_migrations`)).toEqual([ + { name: "20240101000000_create_migrated_users" }, + ]) + }), + ) + } finally { + await rm(migrationsFolder, { recursive: true, force: true }) + } +}) diff --git a/packages/effect-drizzle-sqlite/tsconfig.json b/packages/effect-drizzle-sqlite/tsconfig.json new file mode 100644 index 0000000000..2bc480ffbb --- /dev/null +++ b/packages/effect-drizzle-sqlite/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/specs/storage/effect-sqlite-package.md b/specs/storage/effect-sqlite-package.md new file mode 100644 index 0000000000..7efaa34eaf --- /dev/null +++ b/specs/storage/effect-sqlite-package.md @@ -0,0 +1,145 @@ +# Effect Drizzle SQLite Package + +## Goal + +Create a small workspace package that vendors the Drizzle `effect-sqlite` adapter shape for our repo. This is not an opencode storage abstraction. It is a local package that ports the Drizzle Effect SQLite implementation so we can use it before/independently of upstream release timing. + +`packages/opencode` will use it internally, but the package itself should be generic: Drizzle + Effect + SQLite. No opencode paths, migrations, tables, transaction hooks, post-commit behavior, or domain language should live in this package. + +## Package Shape + +Add a package similar in style to `packages/http-recorder`: + +- `packages/effect-drizzle-sqlite/package.json` +- `packages/effect-drizzle-sqlite/src/index.ts` +- `packages/effect-drizzle-sqlite/src/effect-sqlite/*` +- `packages/effect-drizzle-sqlite/src/sqlite-core/effect/*` +- `packages/effect-drizzle-sqlite/test/sqlite.test.ts` + +Package name: + +- `@opencode-ai/effect-drizzle-sqlite` + +Initial exports: + +```ts +export { EffectLogger } from "drizzle-orm/effect-core" +export * from "./effect-sqlite/driver" +export * from "./effect-sqlite/session" +export { migrate } from "./effect-sqlite/migrator" +export * as EffectDrizzleSqlite from "." +``` + +The package should follow Drizzle's adapter naming and semantics as closely as possible. Think of it as a vendored `drizzle-orm/effect-sqlite` package surface, not as a new storage service API. + +## Upstream References + +Use these as implementation references instead of inventing a custom API: + +- Drizzle Effect Postgres current RC: + - `/Users/kit/code/open-source/drizzle-orm-rc4-pr/drizzle-orm/src/effect-core/query-effect.ts` + - `/Users/kit/code/open-source/drizzle-orm-rc4-pr/integration-tests/tests/pg/effect-sql.test.ts` +- SQLite Effect branch/reference: + - `/Users/kit/code/open-source/drizzle-orm-beta16/drizzle-orm/src/up-migrations/effect-sqlite.ts` + - `/Users/kit/code/open-source/drizzle-orm-beta16/integration-tests/tests/sqlite/effect-sql.test.ts` + - `/Users/kit/code/open-source/drizzle-orm-beta16/drizzle-orm/type-tests/sqlite/effect.ts` +- Effect SQLite client source of truth: + - `/Users/kit/code/open-source/effect-smol/packages/sql/sqlite-bun/src/SqliteClient.ts` + - `/Users/kit/code/open-source/effect-smol/packages/sql/sqlite-node/test/Client.test.ts` + - `/Users/kit/code/open-source/effect-smol/packages/sql/sqlite-node/test/SqliteMigrator.test.ts` + +Important API patterns from those references: + +- Drizzle queries are Effect-yieldable: `yield* db.select().from(table)`. +- Transactions are Effect values: `yield* db.transaction((tx) => Effect.gen(...), { behavior: "immediate" })`. +- SQLite clients come from Effect layers such as `SqliteClient.layer({ filename })`. +- Migrations can run through Effect SQL/SQLite migrator mechanisms or Drizzle's `effect-sqlite/migrator` when available. + +## Public Surface + +Do not invent an `Interface` abstraction unless the Drizzle port already has one. The public surface should mirror Drizzle's Effect adapters: + +```ts +const db = yield * EffectDrizzleSqlite.make({ relations }).pipe(Effect.provide(EffectDrizzleSqlite.DefaultServices)) + +yield * db.select().from(users) +yield * + db.transaction( + (tx) => + Effect.gen(function* () { + yield* tx.insert(users).values({ name: "Ada" }) + }), + { behavior: "immediate" }, + ) +``` + +Notes: + +- `make` / `makeWithDefaults` should match the Drizzle Effect SQLite branch as much as possible. +- `DefaultServices` should provide Drizzle's default logger/cache services, same as Effect Postgres. +- The package should depend on Effect SQL SQLite clients (`@effect/sql-sqlite-bun` and/or node) the same way the Drizzle branch does. +- Opencode-specific path/channel selection stays in `packages/opencode`. + +## Opencode Adoption Notes + +These are not package requirements, but they matter for the later opencode adoption PR. + +The current `packages/opencode/src/storage/db.ts` has two non-obvious semantics that the opencode wrapper must preserve when it consumes this adapter: + +- Nested `Database.use` inside `Database.transaction` sees the current transaction, not the root client. +- `Database.effect` queues post-commit side effects while inside a transaction, and runs immediately outside a transaction. + +The opencode wrapper can implement that using Effect context instead of `LocalContext`: + +- A private transaction context holding `{ tx, afterCommit }`. +- `withDb`/`db` methods read the current transaction context if present, otherwise use the root db. +- `transaction` installs a transaction context around the effect. +- Nested transactions can either reuse the existing tx initially, matching current behavior, or later use explicit savepoints if needed. + +Do not remove this behavior while moving opencode to Effect SQLite. `SyncEvent.run` depends on transaction composability and `behavior: "immediate"` for sequencing correctness. + +## Migration Strategy + +1. Add `@opencode-ai/effect-drizzle-sqlite` with a minimal in-memory/file SQLite test schema. +2. Port the Drizzle Effect SQLite adapter from the SQLite branch into the package, preserving upstream names and API shape. +3. Test adapter-level guarantees: + - query builders are yieldable Effect values, + - `transaction(..., { behavior: "immediate" })` commits successful writes, + - failed transaction rolls back, + - migrations run once and in order, + - close finalizer closes the underlying SQLite database. +4. Add `@opencode-ai/effect-drizzle-sqlite` as a dependency of `packages/opencode`. +5. Port `packages/opencode/src/storage/db.ts` to be a thin compatibility wrapper over the adapter plus opencode-specific transaction/post-commit context. +6. Keep existing call sites working first: + - `Database.Client()` + - `Database.use(...)` + - `Database.transaction(...)` + - `Database.effect(...)` +7. After compatibility is stable, migrate call sites from callback-style `Database.use` to yielding Effect Drizzle queries directly. +8. Only then build domain stores like session/message/project stores on top of opencode's storage wrapper. + +## Why This Is Cleaner Than Starting With SessionStorage + +`SessionStorage` is a useful domain seam, but it does not answer the core adapter problem: how to make Drizzle SQLite Effect-native in this repo. + +An Effect Drizzle SQLite package lets us vendor the adapter once. Then opencode can build its own storage wrapper on top, and `SessionStorage`, `MessageStorage`, event store, and projector writes can all share the same transaction and migration model. + +## Open Questions + +- Which client should the first package target: `@effect/sql-sqlite-bun`, `@effect/sql-sqlite-node`, or both behind separate layers? +- How much source should we copy from the Drizzle branch versus import from catalog `drizzle-orm` internals? +- What is the update path once Drizzle upstream ships `effect-sqlite`? +- Should `afterCommit` stay opencode-specific until event publishing moves? Default answer: yes. +- Should the compatibility wrapper preserve synchronous return types temporarily, or should the migration intentionally force Effect call sites? +- Do CLI/admin raw SQL and sqlite shell stay in `packages/opencode`, or does the storage package expose backend capabilities for them? + +## Recommended First PR + +Make the first PR package-only and intentionally boring: + +- Add `packages/effect-drizzle-sqlite`. +- Use a tiny test schema, not opencode domain tables. +- Prove Effect Drizzle SQLite queries, transactions, and migrations. +- Do not migrate `packages/opencode` yet except possibly adding the dependency if needed for typechecking. + +That gives us a focused place to validate the Effect SQLite approach before disturbing opencode's current database runtime.