From 200bce83f221b8f39f1824dd7366d77c4eef9d3b Mon Sep 17 00:00:00 2001 From: Zach Grimaldi Date: Mon, 25 Aug 2025 09:10:31 -0400 Subject: [PATCH 1/7] feat: REST-based D1 --- drizzle-orm/src/d1-rest/driver.ts | 75 ++++++ drizzle-orm/src/d1-rest/index.ts | 3 + drizzle-orm/src/d1-rest/migrator.ts | 49 ++++ drizzle-orm/src/d1-rest/session.ts | 383 ++++++++++++++++++++++++++++ 4 files changed, 510 insertions(+) create mode 100644 drizzle-orm/src/d1-rest/driver.ts create mode 100644 drizzle-orm/src/d1-rest/index.ts create mode 100644 drizzle-orm/src/d1-rest/migrator.ts create mode 100644 drizzle-orm/src/d1-rest/session.ts diff --git a/drizzle-orm/src/d1-rest/driver.ts b/drizzle-orm/src/d1-rest/driver.ts new file mode 100644 index 0000000000..ee8149ebff --- /dev/null +++ b/drizzle-orm/src/d1-rest/driver.ts @@ -0,0 +1,75 @@ +import type { BatchItem, BatchResponse } from '~/batch.ts'; +import { entityKind } from '~/entity.ts'; +import { DefaultLogger } from '~/logger.ts'; +import { createTableRelationsHelpers, extractTablesRelationalConfig } from '~/relations.ts'; +import type { ExtractTablesWithRelations, RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; +import { BaseSQLiteDatabase } from '~/sqlite-core/db.ts'; +import { SQLiteAsyncDialect } from '~/sqlite-core/dialect.ts'; +import type { DrizzleConfig } from '~/utils.ts'; +import { D1RestSession } from './session.ts'; + +export interface D1RestCredentials { + /** The Cloudflare account ID where the D1 database is located */ + accountId: string; + /** The D1 database ID */ + databaseId: string; + /** The Cloudflare API token for the account with D1:edit permissions */ + token: string; +} + +export interface D1RestResult { + rows?: T[]; +} + +export class D1RestDatabase< + TSchema extends Record = Record, +> extends BaseSQLiteDatabase<'async', D1RestResult, TSchema> { + static override readonly [entityKind]: string = 'D1RestDatabase'; + + /** @internal */ + declare readonly session: D1RestSession>; + + async batch, T extends Readonly<[U, ...U[]]>>( + batch: T, + ): Promise> { + return this.session.batch(batch) as Promise>; + } +} + +export function drizzle = Record>( + credentials: D1RestCredentials, + config: DrizzleConfig = {}, +): D1RestDatabase & { + $client: D1RestCredentials; +} { + const dialect = new SQLiteAsyncDialect({ casing: config.casing }); + let logger; + if (config.logger === true) { + logger = new DefaultLogger(); + } else if (config.logger !== false) { + logger = config.logger; + } + + let schema: RelationalSchemaConfig | undefined; + if (config.schema) { + const tablesConfig = extractTablesRelationalConfig( + config.schema, + createTableRelationsHelpers, + ); + schema = { + fullSchema: config.schema, + schema: tablesConfig.tables, + tableNamesMap: tablesConfig.tableNamesMap, + }; + } + + const session = new D1RestSession(credentials, dialect, schema, { logger, cache: config.cache }); + const db = new D1RestDatabase('async', dialect, session, schema) as D1RestDatabase; + (db as any).$client = credentials; + (db as any).$cache = config.cache; + if ((db as any).$cache) { + (db as any).$cache['invalidate'] = config.cache?.onMutate; + } + + return db as any; +} diff --git a/drizzle-orm/src/d1-rest/index.ts b/drizzle-orm/src/d1-rest/index.ts new file mode 100644 index 0000000000..483c58446c --- /dev/null +++ b/drizzle-orm/src/d1-rest/index.ts @@ -0,0 +1,3 @@ +export * from './driver.ts'; +export * from './session.ts'; +export * from './migrator.ts'; diff --git a/drizzle-orm/src/d1-rest/migrator.ts b/drizzle-orm/src/d1-rest/migrator.ts new file mode 100644 index 0000000000..7155eb0777 --- /dev/null +++ b/drizzle-orm/src/d1-rest/migrator.ts @@ -0,0 +1,49 @@ +import type { MigrationConfig } from '~/migrator.ts'; +import { readMigrationFiles } from '~/migrator.ts'; +import { sql } from '~/sql/sql.ts'; +import type { D1RestDatabase } from './driver.ts'; + +export async function migrate>( + db: D1RestDatabase, + config: MigrationConfig, +) { + const migrations = readMigrationFiles(config); + const migrationsTable = config.migrationsTable ?? '__drizzle_migrations'; + + const migrationTableCreate = sql` + CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ) + `; + await db.session.run(migrationTableCreate); + + const dbMigrations = await db.values<[number, string, string]>( + sql`SELECT id, hash, created_at FROM ${sql.identifier(migrationsTable)} ORDER BY created_at DESC LIMIT 1`, + ); + + const lastDbMigration = dbMigrations[0] ?? undefined; + + const statementToBatch = []; + + for (const migration of migrations) { + if (!lastDbMigration || Number(lastDbMigration[2])! < migration.folderMillis) { + for (const stmt of migration.sql) { + statementToBatch.push(db.run(sql.raw(stmt))); + } + + statementToBatch.push( + db.run( + sql`INSERT INTO ${sql.identifier(migrationsTable)} ("hash", "created_at") VALUES(${ + sql.raw(`'${migration.hash}'`) + }, ${sql.raw(`${migration.folderMillis}`)})`, + ), + ); + } + } + + if (statementToBatch.length > 0) { + await db.session.batch(statementToBatch); + } +} diff --git a/drizzle-orm/src/d1-rest/session.ts b/drizzle-orm/src/d1-rest/session.ts new file mode 100644 index 0000000000..837179a14c --- /dev/null +++ b/drizzle-orm/src/d1-rest/session.ts @@ -0,0 +1,383 @@ +import type { BatchItem } from '~/batch.ts'; +import { type Cache, NoopCache } from '~/cache/core/index.ts'; +import type { WithCacheConfig } from '~/cache/core/types.ts'; +import { entityKind } from '~/entity.ts'; +import type { Logger } from '~/logger.ts'; +import { NoopLogger } from '~/logger.ts'; + +// Define fetch function type to avoid dependency on @cloudflare/workers-types +type FetchFunction = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise<{ + json(): Promise; + ok: boolean; + status: number; +}>; + +const globalFetch = (globalThis as any).fetch as FetchFunction; +import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; +import type { PreparedQuery } from '~/session.ts'; +import { fillPlaceholders, type Query, sql } from '~/sql/sql.ts'; +import type { SQLiteAsyncDialect } from '~/sqlite-core/dialect.ts'; +import { SQLiteTransaction } from '~/sqlite-core/index.ts'; +import type { SelectedFieldsOrdered } from '~/sqlite-core/query-builders/select.types.ts'; +import type { + PreparedQueryConfig as PreparedQueryConfigBase, + SQLiteExecuteMethod, + SQLiteTransactionConfig, +} from '~/sqlite-core/session.ts'; +import { SQLitePreparedQuery, SQLiteSession } from '~/sqlite-core/session.ts'; +import { mapResultRow } from '~/utils.ts'; +import type { D1RestCredentials, D1RestResult } from './driver.ts'; + +export interface D1RestSessionOptions { + logger?: Logger; + cache?: Cache; +} + +type PreparedQueryConfig = Omit; + +type D1ApiResponse = + | { + success: true; + result: Array<{ + results: + | any[] + | { + columns: string[]; + rows: any[][]; + }; + meta: { + changed_db: boolean; + changes: number; + duration: number; + last_row_id: number; + rows_read: number; + rows_written: number; + served_by_primary: boolean; + served_by_region: string; + size_after: number; + timings: { + sql_duration_ms: number; + }; + }; + success: boolean; + }>; + } + | { + success: false; + errors: Array<{ code: number; message: string }>; + }; + +export class D1RestSession< + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +> extends SQLiteSession<'async', D1RestResult, TFullSchema, TSchema> { + static override readonly [entityKind]: string = 'D1RestSession'; + + private logger: Logger; + private cache: Cache; + + constructor( + private credentials: D1RestCredentials, + dialect: SQLiteAsyncDialect, + private schema: RelationalSchemaConfig | undefined, + private options: D1RestSessionOptions = {}, + ) { + super(dialect); + this.logger = options.logger ?? new NoopLogger(); + this.cache = options.cache ?? new NoopCache(); + } + + prepareQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + isResponseInArrayMode: boolean, + customResultMapper?: (rows: unknown[][]) => unknown, + queryMetadata?: { + type: 'select' | 'update' | 'delete' | 'insert'; + tables: string[]; + }, + cacheConfig?: WithCacheConfig, + ): D1RestPreparedQuery { + return new D1RestPreparedQuery( + this, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper, + ); + } + + async batch[] | readonly BatchItem<'sqlite'>[]>(queries: T) { + const preparedQueries: PreparedQuery[] = []; + const builtQueries: { sql: string }[] = []; + + for (const query of queries) { + const preparedQuery = query._prepare(); + const builtQuery = preparedQuery.getQuery(); + preparedQueries.push(preparedQuery); + + if (builtQuery.params.length > 0) { + // For parameterized queries, we need to substitute parameters manually + let sql = builtQuery.sql; + for (let i = 0; i < builtQuery.params.length; i++) { + const param = builtQuery.params[i]; + const value = typeof param === 'string' ? `'${param.replace(/'/g, "''")}'` : String(param); + sql = sql.replace('?', value); + } + builtQueries.push({ sql }); + } else { + builtQueries.push({ sql: builtQuery.sql }); + } + } + + const batchSql = builtQueries.map((q) => q.sql).join('; '); + const { accountId, databaseId, token } = this.credentials; + + const response = await globalFetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ sql: batchSql }), + }, + ); + + const data = await response.json() as D1ApiResponse; + + if (!data.success) { + throw new Error( + data.errors.map((error) => `${error.code}: ${error.message}`).join('\n'), + ); + } + + const batchResults = data.result.map((result) => { + const res = result.results; + const rows = Array.isArray(res) ? res : res.rows; + return { rows }; + }); + + return batchResults.map((result, i) => preparedQueries[i]!.mapResult(result, true)); + } + + async executeQuery(sql: string, params: unknown[], method: 'run' | 'all' | 'values' | 'get'): Promise { + const { accountId, databaseId, token } = this.credentials; + + const endpoint = method === 'values' ? 'raw' : 'query'; + const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/${endpoint}`; + + const response = await globalFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ sql, params }), + }); + + const data = await response.json() as D1ApiResponse; + + if (!data.success) { + throw new Error( + data.errors.map((error) => `${error.code}: ${error.message}`).join('\n'), + ); + } + + const result = data.result[0]?.results; + if (!result) { + return { rows: [] }; + } + + const rows = Array.isArray(result) ? result : result.rows; + return { rows }; + } + + override extractRawAllValueFromBatchResult(result: unknown): unknown { + return (result as D1RestResult).rows; + } + + override extractRawGetValueFromBatchResult(result: unknown): unknown { + return (result as D1RestResult).rows?.[0]; + } + + override extractRawValuesValueFromBatchResult(result: unknown): unknown { + return (result as D1RestResult).rows; + } + + override async transaction( + transaction: (tx: D1RestTransaction) => T | Promise, + config?: SQLiteTransactionConfig, + ): Promise { + const tx = new D1RestTransaction('async', this.dialect, this, this.schema); + await this.run(sql.raw(`begin${config?.behavior ? ' ' + config.behavior : ''}`)); + try { + const result = await transaction(tx); + await this.run(sql`commit`); + return result; + } catch (err) { + await this.run(sql`rollback`); + throw err; + } + } +} + +export class D1RestTransaction< + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +> extends SQLiteTransaction<'async', D1RestResult, TFullSchema, TSchema> { + static override readonly [entityKind]: string = 'D1RestTransaction'; + + override async transaction(transaction: (tx: D1RestTransaction) => Promise): Promise { + const savepointName = `sp${this.nestedIndex}`; + const tx = new D1RestTransaction('async', this.dialect, this.session, this.schema, this.nestedIndex + 1); + await this.session.run(sql.raw(`savepoint ${savepointName}`)); + try { + const result = await transaction(tx); + await this.session.run(sql.raw(`release savepoint ${savepointName}`)); + return result; + } catch (err) { + await this.session.run(sql.raw(`rollback to savepoint ${savepointName}`)); + throw err; + } + } +} + +export class D1RestPreparedQuery extends SQLitePreparedQuery< + { type: 'async'; run: D1RestResult; all: T['all']; get: T['get']; values: T['values']; execute: T['execute'] } +> { + static override readonly [entityKind]: string = 'D1RestPreparedQuery'; + + /** @internal */ + customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown; + + /** @internal */ + fields?: SelectedFieldsOrdered; + + constructor( + private session: D1RestSession, + query: Query, + private logger: Logger, + cache: Cache, + queryMetadata: { + type: 'select' | 'update' | 'delete' | 'insert'; + tables: string[]; + } | undefined, + cacheConfig: WithCacheConfig | undefined, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + private _isResponseInArrayMode: boolean, + customResultMapper?: (rows: unknown[][]) => unknown, + ) { + super('async', executeMethod, query, cache, queryMetadata, cacheConfig); + this.customResultMapper = customResultMapper; + this.fields = fields; + } + + async run(placeholderValues?: Record): Promise { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); + this.logger.logQuery(this.query.sql, params); + return await this.queryWithCache(this.query.sql, params, async () => { + return this.session.executeQuery(this.query.sql, params, 'run'); + }); + } + + async all(placeholderValues?: Record): Promise { + const { fields, query, logger, customResultMapper } = this; + if (!fields && !customResultMapper) { + const params = fillPlaceholders(query.params, placeholderValues ?? {}); + logger.logQuery(query.sql, params); + return await this.queryWithCache(query.sql, params, async () => { + const result = await this.session.executeQuery(query.sql, params, 'all'); + return this.mapAllResult(result.rows!); + }); + } + + const rows = await this.values(placeholderValues); + return this.mapAllResult(rows); + } + + override mapAllResult(rows: unknown, isFromBatch?: boolean): unknown { + if (isFromBatch) { + rows = (rows as D1RestResult).rows; + } + + if (!this.fields && !this.customResultMapper) { + return rows; + } + + if (this.customResultMapper) { + return this.customResultMapper(rows as unknown[][]); + } + + return (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap)); + } + + async get(placeholderValues?: Record): Promise { + const { fields, joinsNotNullableMap, query, logger, customResultMapper } = this; + if (!fields && !customResultMapper) { + const params = fillPlaceholders(query.params, placeholderValues ?? {}); + logger.logQuery(query.sql, params); + return await this.queryWithCache(query.sql, params, async () => { + const result = await this.session.executeQuery(query.sql, params, 'get'); + return result.rows?.[0]; + }); + } + + const rows = await this.values(placeholderValues); + + if (!rows[0]) { + return undefined; + } + + if (customResultMapper) { + return customResultMapper(rows) as T['all']; + } + + return mapResultRow(fields!, rows[0], joinsNotNullableMap); + } + + override mapGetResult(result: unknown, isFromBatch?: boolean): unknown { + if (isFromBatch) { + result = (result as D1RestResult).rows?.[0]; + } + + if (!this.fields && !this.customResultMapper) { + return result; + } + + if (this.customResultMapper) { + return this.customResultMapper([result as unknown[]]) as T['all']; + } + + return mapResultRow(this.fields!, result as unknown[], this.joinsNotNullableMap); + } + + async values(placeholderValues?: Record): Promise { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); + this.logger.logQuery(this.query.sql, params); + return await this.queryWithCache(this.query.sql, params, async () => { + const result = await this.session.executeQuery(this.query.sql, params, 'values'); + return result.rows as T[]; + }); + } + + /** @internal */ + isResponseInArrayMode(): boolean { + return this._isResponseInArrayMode; + } +} \ No newline at end of file From feba6fcdfd6e06d9fd8edd7c4821480002d8ff35 Mon Sep 17 00:00:00 2001 From: Zach Grimaldi Date: Mon, 25 Aug 2025 09:22:38 -0400 Subject: [PATCH 2/7] chore: rename d1-http to match existing --- drizzle-orm/src/{d1-rest => d1-http}/driver.ts | 0 drizzle-orm/src/{d1-rest => d1-http}/index.ts | 0 drizzle-orm/src/{d1-rest => d1-http}/migrator.ts | 0 drizzle-orm/src/{d1-rest => d1-http}/session.ts | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename drizzle-orm/src/{d1-rest => d1-http}/driver.ts (100%) rename drizzle-orm/src/{d1-rest => d1-http}/index.ts (100%) rename drizzle-orm/src/{d1-rest => d1-http}/migrator.ts (100%) rename drizzle-orm/src/{d1-rest => d1-http}/session.ts (100%) diff --git a/drizzle-orm/src/d1-rest/driver.ts b/drizzle-orm/src/d1-http/driver.ts similarity index 100% rename from drizzle-orm/src/d1-rest/driver.ts rename to drizzle-orm/src/d1-http/driver.ts diff --git a/drizzle-orm/src/d1-rest/index.ts b/drizzle-orm/src/d1-http/index.ts similarity index 100% rename from drizzle-orm/src/d1-rest/index.ts rename to drizzle-orm/src/d1-http/index.ts diff --git a/drizzle-orm/src/d1-rest/migrator.ts b/drizzle-orm/src/d1-http/migrator.ts similarity index 100% rename from drizzle-orm/src/d1-rest/migrator.ts rename to drizzle-orm/src/d1-http/migrator.ts diff --git a/drizzle-orm/src/d1-rest/session.ts b/drizzle-orm/src/d1-http/session.ts similarity index 100% rename from drizzle-orm/src/d1-rest/session.ts rename to drizzle-orm/src/d1-http/session.ts From c5f26713c256cdb7a9c6035e89c19829d14e1c89 Mon Sep 17 00:00:00 2001 From: Zach Grimaldi Date: Mon, 25 Aug 2025 09:41:02 -0400 Subject: [PATCH 3/7] chore: finish renaming to D1Http --- drizzle-orm/src/d1-http/driver.ts | 24 ++++++------- drizzle-orm/src/d1-http/migrator.ts | 6 ++-- drizzle-orm/src/d1-http/session.ts | 54 ++++++++++++++--------------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/drizzle-orm/src/d1-http/driver.ts b/drizzle-orm/src/d1-http/driver.ts index ee8149ebff..156d251c41 100644 --- a/drizzle-orm/src/d1-http/driver.ts +++ b/drizzle-orm/src/d1-http/driver.ts @@ -6,9 +6,9 @@ import type { ExtractTablesWithRelations, RelationalSchemaConfig, TablesRelation import { BaseSQLiteDatabase } from '~/sqlite-core/db.ts'; import { SQLiteAsyncDialect } from '~/sqlite-core/dialect.ts'; import type { DrizzleConfig } from '~/utils.ts'; -import { D1RestSession } from './session.ts'; +import { D1HttpSession } from './session.ts'; -export interface D1RestCredentials { +export interface D1HttpCredentials { /** The Cloudflare account ID where the D1 database is located */ accountId: string; /** The D1 database ID */ @@ -17,17 +17,17 @@ export interface D1RestCredentials { token: string; } -export interface D1RestResult { +export interface D1HttpResult { rows?: T[]; } -export class D1RestDatabase< +export class D1HttpDatabase< TSchema extends Record = Record, -> extends BaseSQLiteDatabase<'async', D1RestResult, TSchema> { - static override readonly [entityKind]: string = 'D1RestDatabase'; +> extends BaseSQLiteDatabase<'async', D1HttpResult, TSchema> { + static override readonly [entityKind]: string = 'D1HttpDatabase'; /** @internal */ - declare readonly session: D1RestSession>; + declare readonly session: D1HttpSession>; async batch, T extends Readonly<[U, ...U[]]>>( batch: T, @@ -37,10 +37,10 @@ export class D1RestDatabase< } export function drizzle = Record>( - credentials: D1RestCredentials, + credentials: D1HttpCredentials, config: DrizzleConfig = {}, -): D1RestDatabase & { - $client: D1RestCredentials; +): D1HttpDatabase & { + $client: D1HttpCredentials; } { const dialect = new SQLiteAsyncDialect({ casing: config.casing }); let logger; @@ -63,8 +63,8 @@ export function drizzle = Record; + const session = new D1HttpSession(credentials, dialect, schema, { logger, cache: config.cache }); + const db = new D1HttpDatabase('async', dialect, session, schema) as D1HttpDatabase; (db as any).$client = credentials; (db as any).$cache = config.cache; if ((db as any).$cache) { diff --git a/drizzle-orm/src/d1-http/migrator.ts b/drizzle-orm/src/d1-http/migrator.ts index 7155eb0777..febd53f76e 100644 --- a/drizzle-orm/src/d1-http/migrator.ts +++ b/drizzle-orm/src/d1-http/migrator.ts @@ -1,10 +1,10 @@ import type { MigrationConfig } from '~/migrator.ts'; import { readMigrationFiles } from '~/migrator.ts'; import { sql } from '~/sql/sql.ts'; -import type { D1RestDatabase } from './driver.ts'; +import type { D1HttpDatabase } from './driver.ts'; export async function migrate>( - db: D1RestDatabase, + db: D1HttpDatabase, config: MigrationConfig, ) { const migrations = readMigrationFiles(config); @@ -46,4 +46,4 @@ export async function migrate>( if (statementToBatch.length > 0) { await db.session.batch(statementToBatch); } -} +} \ No newline at end of file diff --git a/drizzle-orm/src/d1-http/session.ts b/drizzle-orm/src/d1-http/session.ts index 837179a14c..12d359794c 100644 --- a/drizzle-orm/src/d1-http/session.ts +++ b/drizzle-orm/src/d1-http/session.ts @@ -33,9 +33,9 @@ import type { } from '~/sqlite-core/session.ts'; import { SQLitePreparedQuery, SQLiteSession } from '~/sqlite-core/session.ts'; import { mapResultRow } from '~/utils.ts'; -import type { D1RestCredentials, D1RestResult } from './driver.ts'; +import type { D1HttpCredentials, D1HttpResult } from './driver.ts'; -export interface D1RestSessionOptions { +export interface D1HttpSessionOptions { logger?: Logger; cache?: Cache; } @@ -74,20 +74,20 @@ type D1ApiResponse = errors: Array<{ code: number; message: string }>; }; -export class D1RestSession< +export class D1HttpSession< TFullSchema extends Record, TSchema extends TablesRelationalConfig, -> extends SQLiteSession<'async', D1RestResult, TFullSchema, TSchema> { - static override readonly [entityKind]: string = 'D1RestSession'; +> extends SQLiteSession<'async', D1HttpResult, TFullSchema, TSchema> { + static override readonly [entityKind]: string = 'D1HttpSession'; private logger: Logger; private cache: Cache; constructor( - private credentials: D1RestCredentials, + private credentials: D1HttpCredentials, dialect: SQLiteAsyncDialect, private schema: RelationalSchemaConfig | undefined, - private options: D1RestSessionOptions = {}, + private options: D1HttpSessionOptions = {}, ) { super(dialect); this.logger = options.logger ?? new NoopLogger(); @@ -105,8 +105,8 @@ export class D1RestSession< tables: string[]; }, cacheConfig?: WithCacheConfig, - ): D1RestPreparedQuery { - return new D1RestPreparedQuery( + ): D1HttpPreparedQuery { + return new D1HttpPreparedQuery( this, query, this.logger, @@ -175,7 +175,7 @@ export class D1RestSession< return batchResults.map((result, i) => preparedQueries[i]!.mapResult(result, true)); } - async executeQuery(sql: string, params: unknown[], method: 'run' | 'all' | 'values' | 'get'): Promise { + async executeQuery(sql: string, params: unknown[], method: 'run' | 'all' | 'values' | 'get'): Promise { const { accountId, databaseId, token } = this.credentials; const endpoint = method === 'values' ? 'raw' : 'query'; @@ -208,22 +208,22 @@ export class D1RestSession< } override extractRawAllValueFromBatchResult(result: unknown): unknown { - return (result as D1RestResult).rows; + return (result as D1HttpResult).rows; } override extractRawGetValueFromBatchResult(result: unknown): unknown { - return (result as D1RestResult).rows?.[0]; + return (result as D1HttpResult).rows?.[0]; } override extractRawValuesValueFromBatchResult(result: unknown): unknown { - return (result as D1RestResult).rows; + return (result as D1HttpResult).rows; } override async transaction( - transaction: (tx: D1RestTransaction) => T | Promise, + transaction: (tx: D1HttpTransaction) => T | Promise, config?: SQLiteTransactionConfig, ): Promise { - const tx = new D1RestTransaction('async', this.dialect, this, this.schema); + const tx = new D1HttpTransaction('async', this.dialect, this, this.schema); await this.run(sql.raw(`begin${config?.behavior ? ' ' + config.behavior : ''}`)); try { const result = await transaction(tx); @@ -236,15 +236,15 @@ export class D1RestSession< } } -export class D1RestTransaction< +export class D1HttpTransaction< TFullSchema extends Record, TSchema extends TablesRelationalConfig, -> extends SQLiteTransaction<'async', D1RestResult, TFullSchema, TSchema> { - static override readonly [entityKind]: string = 'D1RestTransaction'; +> extends SQLiteTransaction<'async', D1HttpResult, TFullSchema, TSchema> { + static override readonly [entityKind]: string = 'D1HttpTransaction'; - override async transaction(transaction: (tx: D1RestTransaction) => Promise): Promise { + override async transaction(transaction: (tx: D1HttpTransaction) => Promise): Promise { const savepointName = `sp${this.nestedIndex}`; - const tx = new D1RestTransaction('async', this.dialect, this.session, this.schema, this.nestedIndex + 1); + const tx = new D1HttpTransaction('async', this.dialect, this.session, this.schema, this.nestedIndex + 1); await this.session.run(sql.raw(`savepoint ${savepointName}`)); try { const result = await transaction(tx); @@ -257,10 +257,10 @@ export class D1RestTransaction< } } -export class D1RestPreparedQuery extends SQLitePreparedQuery< - { type: 'async'; run: D1RestResult; all: T['all']; get: T['get']; values: T['values']; execute: T['execute'] } +export class D1HttpPreparedQuery extends SQLitePreparedQuery< + { type: 'async'; run: D1HttpResult; all: T['all']; get: T['get']; values: T['values']; execute: T['execute'] } > { - static override readonly [entityKind]: string = 'D1RestPreparedQuery'; + static override readonly [entityKind]: string = 'D1HttpPreparedQuery'; /** @internal */ customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown; @@ -269,7 +269,7 @@ export class D1RestPreparedQuery, + private session: D1HttpSession, query: Query, private logger: Logger, cache: Cache, @@ -288,7 +288,7 @@ export class D1RestPreparedQuery): Promise { + async run(placeholderValues?: Record): Promise { const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); this.logger.logQuery(this.query.sql, params); return await this.queryWithCache(this.query.sql, params, async () => { @@ -313,7 +313,7 @@ export class D1RestPreparedQuery Date: Mon, 25 Aug 2025 09:43:32 -0400 Subject: [PATCH 4/7] chore: fmt --- drizzle-orm/src/d1-http/driver.ts | 96 ++-- drizzle-orm/src/d1-http/migrator.ts | 70 +-- drizzle-orm/src/d1-http/session.ts | 695 ++++++++++++++-------------- 3 files changed, 435 insertions(+), 426 deletions(-) diff --git a/drizzle-orm/src/d1-http/driver.ts b/drizzle-orm/src/d1-http/driver.ts index 156d251c41..fd49fae0a7 100644 --- a/drizzle-orm/src/d1-http/driver.ts +++ b/drizzle-orm/src/d1-http/driver.ts @@ -9,67 +9,67 @@ import type { DrizzleConfig } from '~/utils.ts'; import { D1HttpSession } from './session.ts'; export interface D1HttpCredentials { - /** The Cloudflare account ID where the D1 database is located */ - accountId: string; - /** The D1 database ID */ - databaseId: string; - /** The Cloudflare API token for the account with D1:edit permissions */ - token: string; + /** The Cloudflare account ID where the D1 database is located */ + accountId: string; + /** The D1 database ID */ + databaseId: string; + /** The Cloudflare API token for the account with D1:edit permissions */ + token: string; } export interface D1HttpResult { - rows?: T[]; + rows?: T[]; } -export class D1HttpDatabase< - TSchema extends Record = Record, -> extends BaseSQLiteDatabase<'async', D1HttpResult, TSchema> { - static override readonly [entityKind]: string = 'D1HttpDatabase'; +export class D1HttpDatabase = Record> extends BaseSQLiteDatabase< + 'async', + D1HttpResult, + TSchema +> { + static override readonly [entityKind]: string = 'D1HttpDatabase'; - /** @internal */ - declare readonly session: D1HttpSession>; + /** @internal */ + declare readonly session: D1HttpSession>; - async batch, T extends Readonly<[U, ...U[]]>>( - batch: T, - ): Promise> { - return this.session.batch(batch) as Promise>; - } + async batch, T extends Readonly<[U, ...U[]]>>(batch: T): Promise> { + return this.session.batch(batch) as Promise>; + } } export function drizzle = Record>( - credentials: D1HttpCredentials, - config: DrizzleConfig = {}, + credentials: D1HttpCredentials, + config: DrizzleConfig = {} ): D1HttpDatabase & { - $client: D1HttpCredentials; + $client: D1HttpCredentials; } { - const dialect = new SQLiteAsyncDialect({ casing: config.casing }); - let logger; - if (config.logger === true) { - logger = new DefaultLogger(); - } else if (config.logger !== false) { - logger = config.logger; - } + const dialect = new SQLiteAsyncDialect({ casing: config.casing }); + let logger; + if (config.logger === true) { + logger = new DefaultLogger(); + } else if (config.logger !== false) { + logger = config.logger; + } - let schema: RelationalSchemaConfig | undefined; - if (config.schema) { - const tablesConfig = extractTablesRelationalConfig( - config.schema, - createTableRelationsHelpers, - ); - schema = { - fullSchema: config.schema, - schema: tablesConfig.tables, - tableNamesMap: tablesConfig.tableNamesMap, - }; - } + let schema: RelationalSchemaConfig | undefined; + if (config.schema) { + const tablesConfig = extractTablesRelationalConfig(config.schema, createTableRelationsHelpers); + schema = { + fullSchema: config.schema, + schema: tablesConfig.tables, + tableNamesMap: tablesConfig.tableNamesMap, + }; + } - const session = new D1HttpSession(credentials, dialect, schema, { logger, cache: config.cache }); - const db = new D1HttpDatabase('async', dialect, session, schema) as D1HttpDatabase; - (db as any).$client = credentials; - (db as any).$cache = config.cache; - if ((db as any).$cache) { - (db as any).$cache['invalidate'] = config.cache?.onMutate; - } + const session = new D1HttpSession(credentials, dialect, schema, { + logger, + cache: config.cache, + }); + const db = new D1HttpDatabase('async', dialect, session, schema) as D1HttpDatabase; + (db as any).$client = credentials; + (db as any).$cache = config.cache; + if ((db as any).$cache) { + (db as any).$cache['invalidate'] = config.cache?.onMutate; + } - return db as any; + return db as any; } diff --git a/drizzle-orm/src/d1-http/migrator.ts b/drizzle-orm/src/d1-http/migrator.ts index febd53f76e..154c87085c 100644 --- a/drizzle-orm/src/d1-http/migrator.ts +++ b/drizzle-orm/src/d1-http/migrator.ts @@ -4,46 +4,46 @@ import { sql } from '~/sql/sql.ts'; import type { D1HttpDatabase } from './driver.ts'; export async function migrate>( - db: D1HttpDatabase, - config: MigrationConfig, + db: D1HttpDatabase, + config: MigrationConfig ) { - const migrations = readMigrationFiles(config); - const migrationsTable = config.migrationsTable ?? '__drizzle_migrations'; + const migrations = readMigrationFiles(config); + const migrationsTable = config.migrationsTable ?? '__drizzle_migrations'; - const migrationTableCreate = sql` + const migrationTableCreate = sql` CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} ( id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric ) `; - await db.session.run(migrationTableCreate); - - const dbMigrations = await db.values<[number, string, string]>( - sql`SELECT id, hash, created_at FROM ${sql.identifier(migrationsTable)} ORDER BY created_at DESC LIMIT 1`, - ); - - const lastDbMigration = dbMigrations[0] ?? undefined; - - const statementToBatch = []; - - for (const migration of migrations) { - if (!lastDbMigration || Number(lastDbMigration[2])! < migration.folderMillis) { - for (const stmt of migration.sql) { - statementToBatch.push(db.run(sql.raw(stmt))); - } - - statementToBatch.push( - db.run( - sql`INSERT INTO ${sql.identifier(migrationsTable)} ("hash", "created_at") VALUES(${ - sql.raw(`'${migration.hash}'`) - }, ${sql.raw(`${migration.folderMillis}`)})`, - ), - ); - } - } - - if (statementToBatch.length > 0) { - await db.session.batch(statementToBatch); - } -} \ No newline at end of file + await db.session.run(migrationTableCreate); + + const dbMigrations = await db.values<[number, string, string]>( + sql`SELECT id, hash, created_at FROM ${sql.identifier(migrationsTable)} ORDER BY created_at DESC LIMIT 1` + ); + + const lastDbMigration = dbMigrations[0] ?? undefined; + + const statementToBatch = []; + + for (const migration of migrations) { + if (!lastDbMigration || Number(lastDbMigration[2])! < migration.folderMillis) { + for (const stmt of migration.sql) { + statementToBatch.push(db.run(sql.raw(stmt))); + } + + statementToBatch.push( + db.run( + sql`INSERT INTO ${sql.identifier(migrationsTable)} ("hash", "created_at") VALUES(${sql.raw( + `'${migration.hash}'` + )}, ${sql.raw(`${migration.folderMillis}`)})` + ) + ); + } + } + + if (statementToBatch.length > 0) { + await db.session.batch(statementToBatch); + } +} diff --git a/drizzle-orm/src/d1-http/session.ts b/drizzle-orm/src/d1-http/session.ts index 12d359794c..a8437d4747 100644 --- a/drizzle-orm/src/d1-http/session.ts +++ b/drizzle-orm/src/d1-http/session.ts @@ -7,16 +7,16 @@ import { NoopLogger } from '~/logger.ts'; // Define fetch function type to avoid dependency on @cloudflare/workers-types type FetchFunction = ( - input: string, - init?: { - method?: string; - headers?: Record; - body?: string; - }, + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + } ) => Promise<{ - json(): Promise; - ok: boolean; - status: number; + json(): Promise; + ok: boolean; + status: number; }>; const globalFetch = (globalThis as any).fetch as FetchFunction; @@ -27,357 +27,366 @@ import type { SQLiteAsyncDialect } from '~/sqlite-core/dialect.ts'; import { SQLiteTransaction } from '~/sqlite-core/index.ts'; import type { SelectedFieldsOrdered } from '~/sqlite-core/query-builders/select.types.ts'; import type { - PreparedQueryConfig as PreparedQueryConfigBase, - SQLiteExecuteMethod, - SQLiteTransactionConfig, + PreparedQueryConfig as PreparedQueryConfigBase, + SQLiteExecuteMethod, + SQLiteTransactionConfig, } from '~/sqlite-core/session.ts'; import { SQLitePreparedQuery, SQLiteSession } from '~/sqlite-core/session.ts'; import { mapResultRow } from '~/utils.ts'; import type { D1HttpCredentials, D1HttpResult } from './driver.ts'; export interface D1HttpSessionOptions { - logger?: Logger; - cache?: Cache; + logger?: Logger; + cache?: Cache; } type PreparedQueryConfig = Omit; type D1ApiResponse = - | { - success: true; - result: Array<{ - results: - | any[] - | { - columns: string[]; - rows: any[][]; - }; - meta: { - changed_db: boolean; - changes: number; - duration: number; - last_row_id: number; - rows_read: number; - rows_written: number; - served_by_primary: boolean; - served_by_region: string; - size_after: number; - timings: { - sql_duration_ms: number; - }; - }; - success: boolean; - }>; - } - | { - success: false; - errors: Array<{ code: number; message: string }>; - }; + | { + success: true; + result: Array<{ + results: + | any[] + | { + columns: string[]; + rows: any[][]; + }; + meta: { + changed_db: boolean; + changes: number; + duration: number; + last_row_id: number; + rows_read: number; + rows_written: number; + served_by_primary: boolean; + served_by_region: string; + size_after: number; + timings: { + sql_duration_ms: number; + }; + }; + success: boolean; + }>; + } + | { + success: false; + errors: Array<{ code: number; message: string }>; + }; export class D1HttpSession< - TFullSchema extends Record, - TSchema extends TablesRelationalConfig, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, > extends SQLiteSession<'async', D1HttpResult, TFullSchema, TSchema> { - static override readonly [entityKind]: string = 'D1HttpSession'; - - private logger: Logger; - private cache: Cache; - - constructor( - private credentials: D1HttpCredentials, - dialect: SQLiteAsyncDialect, - private schema: RelationalSchemaConfig | undefined, - private options: D1HttpSessionOptions = {}, - ) { - super(dialect); - this.logger = options.logger ?? new NoopLogger(); - this.cache = options.cache ?? new NoopCache(); - } - - prepareQuery( - query: Query, - fields: SelectedFieldsOrdered | undefined, - executeMethod: SQLiteExecuteMethod, - isResponseInArrayMode: boolean, - customResultMapper?: (rows: unknown[][]) => unknown, - queryMetadata?: { - type: 'select' | 'update' | 'delete' | 'insert'; - tables: string[]; - }, - cacheConfig?: WithCacheConfig, - ): D1HttpPreparedQuery { - return new D1HttpPreparedQuery( - this, - query, - this.logger, - this.cache, - queryMetadata, - cacheConfig, - fields, - executeMethod, - isResponseInArrayMode, - customResultMapper, - ); - } - - async batch[] | readonly BatchItem<'sqlite'>[]>(queries: T) { - const preparedQueries: PreparedQuery[] = []; - const builtQueries: { sql: string }[] = []; - - for (const query of queries) { - const preparedQuery = query._prepare(); - const builtQuery = preparedQuery.getQuery(); - preparedQueries.push(preparedQuery); - - if (builtQuery.params.length > 0) { - // For parameterized queries, we need to substitute parameters manually - let sql = builtQuery.sql; - for (let i = 0; i < builtQuery.params.length; i++) { - const param = builtQuery.params[i]; - const value = typeof param === 'string' ? `'${param.replace(/'/g, "''")}'` : String(param); - sql = sql.replace('?', value); - } - builtQueries.push({ sql }); - } else { - builtQueries.push({ sql: builtQuery.sql }); - } - } - - const batchSql = builtQueries.map((q) => q.sql).join('; '); - const { accountId, databaseId, token } = this.credentials; - - const response = await globalFetch( - `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ sql: batchSql }), - }, - ); - - const data = await response.json() as D1ApiResponse; - - if (!data.success) { - throw new Error( - data.errors.map((error) => `${error.code}: ${error.message}`).join('\n'), - ); - } - - const batchResults = data.result.map((result) => { - const res = result.results; - const rows = Array.isArray(res) ? res : res.rows; - return { rows }; - }); - - return batchResults.map((result, i) => preparedQueries[i]!.mapResult(result, true)); - } - - async executeQuery(sql: string, params: unknown[], method: 'run' | 'all' | 'values' | 'get'): Promise { - const { accountId, databaseId, token } = this.credentials; - - const endpoint = method === 'values' ? 'raw' : 'query'; - const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/${endpoint}`; - - const response = await globalFetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ sql, params }), - }); - - const data = await response.json() as D1ApiResponse; - - if (!data.success) { - throw new Error( - data.errors.map((error) => `${error.code}: ${error.message}`).join('\n'), - ); - } - - const result = data.result[0]?.results; - if (!result) { - return { rows: [] }; - } - - const rows = Array.isArray(result) ? result : result.rows; - return { rows }; - } - - override extractRawAllValueFromBatchResult(result: unknown): unknown { - return (result as D1HttpResult).rows; - } - - override extractRawGetValueFromBatchResult(result: unknown): unknown { - return (result as D1HttpResult).rows?.[0]; - } - - override extractRawValuesValueFromBatchResult(result: unknown): unknown { - return (result as D1HttpResult).rows; - } - - override async transaction( - transaction: (tx: D1HttpTransaction) => T | Promise, - config?: SQLiteTransactionConfig, - ): Promise { - const tx = new D1HttpTransaction('async', this.dialect, this, this.schema); - await this.run(sql.raw(`begin${config?.behavior ? ' ' + config.behavior : ''}`)); - try { - const result = await transaction(tx); - await this.run(sql`commit`); - return result; - } catch (err) { - await this.run(sql`rollback`); - throw err; - } - } + static override readonly [entityKind]: string = 'D1HttpSession'; + + private logger: Logger; + private cache: Cache; + + constructor( + private credentials: D1HttpCredentials, + dialect: SQLiteAsyncDialect, + private schema: RelationalSchemaConfig | undefined, + private options: D1HttpSessionOptions = {} + ) { + super(dialect); + this.logger = options.logger ?? new NoopLogger(); + this.cache = options.cache ?? new NoopCache(); + } + + prepareQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + isResponseInArrayMode: boolean, + customResultMapper?: (rows: unknown[][]) => unknown, + queryMetadata?: { + type: 'select' | 'update' | 'delete' | 'insert'; + tables: string[]; + }, + cacheConfig?: WithCacheConfig + ): D1HttpPreparedQuery { + return new D1HttpPreparedQuery( + this, + query, + this.logger, + this.cache, + queryMetadata, + cacheConfig, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper + ); + } + + async batch[] | readonly BatchItem<'sqlite'>[]>(queries: T) { + const preparedQueries: PreparedQuery[] = []; + const builtQueries: { sql: string }[] = []; + + for (const query of queries) { + const preparedQuery = query._prepare(); + const builtQuery = preparedQuery.getQuery(); + preparedQueries.push(preparedQuery); + + if (builtQuery.params.length > 0) { + // For parameterized queries, we need to substitute parameters manually + let sql = builtQuery.sql; + for (let i = 0; i < builtQuery.params.length; i++) { + const param = builtQuery.params[i]; + const value = typeof param === 'string' ? `'${param.replace(/'/g, "''")}'` : String(param); + sql = sql.replace('?', value); + } + builtQueries.push({ sql }); + } else { + builtQueries.push({ sql: builtQuery.sql }); + } + } + + const batchSql = builtQueries.map(q => q.sql).join('; '); + const { accountId, databaseId, token } = this.credentials; + + const response = await globalFetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ sql: batchSql }), + } + ); + + const data = (await response.json()) as D1ApiResponse; + + if (!data.success) { + throw new Error(data.errors.map(error => `${error.code}: ${error.message}`).join('\n')); + } + + const batchResults = data.result.map(result => { + const res = result.results; + const rows = Array.isArray(res) ? res : res.rows; + return { rows }; + }); + + return batchResults.map((result, i) => preparedQueries[i]!.mapResult(result, true)); + } + + async executeQuery( + sql: string, + params: unknown[], + method: 'run' | 'all' | 'values' | 'get' + ): Promise { + const { accountId, databaseId, token } = this.credentials; + + const endpoint = method === 'values' ? 'raw' : 'query'; + const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/${endpoint}`; + + const response = await globalFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ sql, params }), + }); + + const data = (await response.json()) as D1ApiResponse; + + if (!data.success) { + throw new Error(data.errors.map(error => `${error.code}: ${error.message}`).join('\n')); + } + + const result = data.result[0]?.results; + if (!result) { + return { rows: [] }; + } + + const rows = Array.isArray(result) ? result : result.rows; + return { rows }; + } + + override extractRawAllValueFromBatchResult(result: unknown): unknown { + return (result as D1HttpResult).rows; + } + + override extractRawGetValueFromBatchResult(result: unknown): unknown { + return (result as D1HttpResult).rows?.[0]; + } + + override extractRawValuesValueFromBatchResult(result: unknown): unknown { + return (result as D1HttpResult).rows; + } + + override async transaction( + transaction: (tx: D1HttpTransaction) => T | Promise, + config?: SQLiteTransactionConfig + ): Promise { + const tx = new D1HttpTransaction('async', this.dialect, this, this.schema); + await this.run(sql.raw(`begin${config?.behavior ? ' ' + config.behavior : ''}`)); + try { + const result = await transaction(tx); + await this.run(sql`commit`); + return result; + } catch (err) { + await this.run(sql`rollback`); + throw err; + } + } } export class D1HttpTransaction< - TFullSchema extends Record, - TSchema extends TablesRelationalConfig, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, > extends SQLiteTransaction<'async', D1HttpResult, TFullSchema, TSchema> { - static override readonly [entityKind]: string = 'D1HttpTransaction'; - - override async transaction(transaction: (tx: D1HttpTransaction) => Promise): Promise { - const savepointName = `sp${this.nestedIndex}`; - const tx = new D1HttpTransaction('async', this.dialect, this.session, this.schema, this.nestedIndex + 1); - await this.session.run(sql.raw(`savepoint ${savepointName}`)); - try { - const result = await transaction(tx); - await this.session.run(sql.raw(`release savepoint ${savepointName}`)); - return result; - } catch (err) { - await this.session.run(sql.raw(`rollback to savepoint ${savepointName}`)); - throw err; - } - } + static override readonly [entityKind]: string = 'D1HttpTransaction'; + + override async transaction( + transaction: (tx: D1HttpTransaction) => Promise + ): Promise { + const savepointName = `sp${this.nestedIndex}`; + const tx = new D1HttpTransaction('async', this.dialect, this.session, this.schema, this.nestedIndex + 1); + await this.session.run(sql.raw(`savepoint ${savepointName}`)); + try { + const result = await transaction(tx); + await this.session.run(sql.raw(`release savepoint ${savepointName}`)); + return result; + } catch (err) { + await this.session.run(sql.raw(`rollback to savepoint ${savepointName}`)); + throw err; + } + } } -export class D1HttpPreparedQuery extends SQLitePreparedQuery< - { type: 'async'; run: D1HttpResult; all: T['all']; get: T['get']; values: T['values']; execute: T['execute'] } -> { - static override readonly [entityKind]: string = 'D1HttpPreparedQuery'; - - /** @internal */ - customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown; - - /** @internal */ - fields?: SelectedFieldsOrdered; - - constructor( - private session: D1HttpSession, - query: Query, - private logger: Logger, - cache: Cache, - queryMetadata: { - type: 'select' | 'update' | 'delete' | 'insert'; - tables: string[]; - } | undefined, - cacheConfig: WithCacheConfig | undefined, - fields: SelectedFieldsOrdered | undefined, - executeMethod: SQLiteExecuteMethod, - private _isResponseInArrayMode: boolean, - customResultMapper?: (rows: unknown[][]) => unknown, - ) { - super('async', executeMethod, query, cache, queryMetadata, cacheConfig); - this.customResultMapper = customResultMapper; - this.fields = fields; - } - - async run(placeholderValues?: Record): Promise { - const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); - this.logger.logQuery(this.query.sql, params); - return await this.queryWithCache(this.query.sql, params, async () => { - return this.session.executeQuery(this.query.sql, params, 'run'); - }); - } - - async all(placeholderValues?: Record): Promise { - const { fields, query, logger, customResultMapper } = this; - if (!fields && !customResultMapper) { - const params = fillPlaceholders(query.params, placeholderValues ?? {}); - logger.logQuery(query.sql, params); - return await this.queryWithCache(query.sql, params, async () => { - const result = await this.session.executeQuery(query.sql, params, 'all'); - return this.mapAllResult(result.rows!); - }); - } - - const rows = await this.values(placeholderValues); - return this.mapAllResult(rows); - } - - override mapAllResult(rows: unknown, isFromBatch?: boolean): unknown { - if (isFromBatch) { - rows = (rows as D1HttpResult).rows; - } - - if (!this.fields && !this.customResultMapper) { - return rows; - } - - if (this.customResultMapper) { - return this.customResultMapper(rows as unknown[][]); - } - - return (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap)); - } - - async get(placeholderValues?: Record): Promise { - const { fields, joinsNotNullableMap, query, logger, customResultMapper } = this; - if (!fields && !customResultMapper) { - const params = fillPlaceholders(query.params, placeholderValues ?? {}); - logger.logQuery(query.sql, params); - return await this.queryWithCache(query.sql, params, async () => { - const result = await this.session.executeQuery(query.sql, params, 'get'); - return result.rows?.[0]; - }); - } - - const rows = await this.values(placeholderValues); - - if (!rows[0]) { - return undefined; - } - - if (customResultMapper) { - return customResultMapper(rows) as T['all']; - } - - return mapResultRow(fields!, rows[0], joinsNotNullableMap); - } - - override mapGetResult(result: unknown, isFromBatch?: boolean): unknown { - if (isFromBatch) { - result = (result as D1HttpResult).rows?.[0]; - } - - if (!this.fields && !this.customResultMapper) { - return result; - } - - if (this.customResultMapper) { - return this.customResultMapper([result as unknown[]]) as T['all']; - } - - return mapResultRow(this.fields!, result as unknown[], this.joinsNotNullableMap); - } - - async values(placeholderValues?: Record): Promise { - const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); - this.logger.logQuery(this.query.sql, params); - return await this.queryWithCache(this.query.sql, params, async () => { - const result = await this.session.executeQuery(this.query.sql, params, 'values'); - return result.rows as T[]; - }); - } - - /** @internal */ - isResponseInArrayMode(): boolean { - return this._isResponseInArrayMode; - } -} \ No newline at end of file +export class D1HttpPreparedQuery extends SQLitePreparedQuery<{ + type: 'async'; + run: D1HttpResult; + all: T['all']; + get: T['get']; + values: T['values']; + execute: T['execute']; +}> { + static override readonly [entityKind]: string = 'D1HttpPreparedQuery'; + + /** @internal */ + customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown; + + /** @internal */ + fields?: SelectedFieldsOrdered; + + constructor( + private session: D1HttpSession, + query: Query, + private logger: Logger, + cache: Cache, + queryMetadata: + | { + type: 'select' | 'update' | 'delete' | 'insert'; + tables: string[]; + } + | undefined, + cacheConfig: WithCacheConfig | undefined, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + private _isResponseInArrayMode: boolean, + customResultMapper?: (rows: unknown[][]) => unknown + ) { + super('async', executeMethod, query, cache, queryMetadata, cacheConfig); + this.customResultMapper = customResultMapper; + this.fields = fields; + } + + async run(placeholderValues?: Record): Promise { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); + this.logger.logQuery(this.query.sql, params); + return await this.queryWithCache(this.query.sql, params, async () => { + return this.session.executeQuery(this.query.sql, params, 'run'); + }); + } + + async all(placeholderValues?: Record): Promise { + const { fields, query, logger, customResultMapper } = this; + if (!fields && !customResultMapper) { + const params = fillPlaceholders(query.params, placeholderValues ?? {}); + logger.logQuery(query.sql, params); + return await this.queryWithCache(query.sql, params, async () => { + const result = await this.session.executeQuery(query.sql, params, 'all'); + return this.mapAllResult(result.rows!); + }); + } + + const rows = await this.values(placeholderValues); + return this.mapAllResult(rows); + } + + override mapAllResult(rows: unknown, isFromBatch?: boolean): unknown { + if (isFromBatch) { + rows = (rows as D1HttpResult).rows; + } + + if (!this.fields && !this.customResultMapper) { + return rows; + } + + if (this.customResultMapper) { + return this.customResultMapper(rows as unknown[][]); + } + + return (rows as unknown[][]).map(row => mapResultRow(this.fields!, row, this.joinsNotNullableMap)); + } + + async get(placeholderValues?: Record): Promise { + const { fields, joinsNotNullableMap, query, logger, customResultMapper } = this; + if (!fields && !customResultMapper) { + const params = fillPlaceholders(query.params, placeholderValues ?? {}); + logger.logQuery(query.sql, params); + return await this.queryWithCache(query.sql, params, async () => { + const result = await this.session.executeQuery(query.sql, params, 'get'); + return result.rows?.[0]; + }); + } + + const rows = await this.values(placeholderValues); + + if (!rows[0]) { + return undefined; + } + + if (customResultMapper) { + return customResultMapper(rows) as T['all']; + } + + return mapResultRow(fields!, rows[0], joinsNotNullableMap); + } + + override mapGetResult(result: unknown, isFromBatch?: boolean): unknown { + if (isFromBatch) { + result = (result as D1HttpResult).rows?.[0]; + } + + if (!this.fields && !this.customResultMapper) { + return result; + } + + if (this.customResultMapper) { + return this.customResultMapper([result as unknown[]]) as T['all']; + } + + return mapResultRow(this.fields!, result as unknown[], this.joinsNotNullableMap); + } + + async values(placeholderValues?: Record): Promise { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); + this.logger.logQuery(this.query.sql, params); + return await this.queryWithCache(this.query.sql, params, async () => { + const result = await this.session.executeQuery(this.query.sql, params, 'values'); + return result.rows as T[]; + }); + } + + /** @internal */ + isResponseInArrayMode(): boolean { + return this._isResponseInArrayMode; + } +} From f18f699b721fff2b4f8bacbfc6426cd0c1fd3325 Mon Sep 17 00:00:00 2001 From: Zach Grimaldi Date: Mon, 25 Aug 2025 11:06:26 -0400 Subject: [PATCH 5/7] feat: enhance D1HttpSession batching and error reporting --- drizzle-orm/src/d1-http/session.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/drizzle-orm/src/d1-http/session.ts b/drizzle-orm/src/d1-http/session.ts index a8437d4747..4b9693f55a 100644 --- a/drizzle-orm/src/d1-http/session.ts +++ b/drizzle-orm/src/d1-http/session.ts @@ -121,6 +121,7 @@ export class D1HttpSession< } async batch[] | readonly BatchItem<'sqlite'>[]>(queries: T) { + // D1 batch API requires all queries in single SQL string, so we manually substitute params const preparedQueries: PreparedQuery[] = []; const builtQueries: { sql: string }[] = []; @@ -130,7 +131,7 @@ export class D1HttpSession< preparedQueries.push(preparedQuery); if (builtQuery.params.length > 0) { - // For parameterized queries, we need to substitute parameters manually + // Manually substitute parameters since D1 batch doesn't support separate params array let sql = builtQuery.sql; for (let i = 0; i < builtQuery.params.length; i++) { const param = builtQuery.params[i]; @@ -143,6 +144,7 @@ export class D1HttpSession< } } + // Combine all SQL statements with semicolons for D1 batch execution const batchSql = builtQueries.map(q => q.sql).join('; '); const { accountId, databaseId, token } = this.credentials; @@ -161,7 +163,9 @@ export class D1HttpSession< const data = (await response.json()) as D1ApiResponse; if (!data.success) { - throw new Error(data.errors.map(error => `${error.code}: ${error.message}`).join('\n')); + // Enhanced error reporting with SQL context for debugging + const errorMessage = data.errors.map(error => `${error.code}: ${error.message}`).join('\n'); + throw new Error(`D1 Batch API Error: ${errorMessage}\nSQL: ${batchSql}`); } const batchResults = data.result.map(result => { @@ -170,7 +174,16 @@ export class D1HttpSession< return { rows }; }); - return batchResults.map((result, i) => preparedQueries[i]!.mapResult(result, true)); + // Map D1 results back to Drizzle prepared queries + // D1 may return more results than queries if SQL contains semicolon-separated statements + return preparedQueries.map((preparedQuery, i) => { + if (!preparedQuery) { + throw new Error(`Missing prepared query at index ${i}`); + } + // Use result at same index, fallback to empty if D1 returns fewer results + const result = batchResults[i] || { rows: [] }; + return preparedQuery.mapResult(result, true); + }); } async executeQuery( @@ -180,6 +193,7 @@ export class D1HttpSession< ): Promise { const { accountId, databaseId, token } = this.credentials; + // Use /raw endpoint for values() method (returns arrays), /query for others (returns objects) const endpoint = method === 'values' ? 'raw' : 'query'; const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/${endpoint}`; @@ -195,7 +209,9 @@ export class D1HttpSession< const data = (await response.json()) as D1ApiResponse; if (!data.success) { - throw new Error(data.errors.map(error => `${error.code}: ${error.message}`).join('\n')); + // Enhanced error reporting with SQL and params for debugging + const errorMessage = data.errors.map(error => `${error.code}: ${error.message}`).join('\n'); + throw new Error(`D1 API Error: ${errorMessage}\nSQL: ${sql}\nParams: ${JSON.stringify(params)}`); } const result = data.result[0]?.results; @@ -203,6 +219,7 @@ export class D1HttpSession< return { rows: [] }; } + // Handle both /raw (arrays) and /query (objects with rows property) response formats const rows = Array.isArray(result) ? result : result.rows; return { rows }; } From 8225c0d5cc2984f1c7212e41140824da29ad44a1 Mon Sep 17 00:00:00 2001 From: Zach Grimaldi Date: Mon, 25 Aug 2025 11:09:12 -0400 Subject: [PATCH 6/7] chore: sort imports --- drizzle-orm/src/d1-http/driver.ts | 2 +- drizzle-orm/src/d1-http/index.ts | 2 +- drizzle-orm/src/d1-http/session.ts | 28 ++++++++++++++-------------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/drizzle-orm/src/d1-http/driver.ts b/drizzle-orm/src/d1-http/driver.ts index fd49fae0a7..2f902b800e 100644 --- a/drizzle-orm/src/d1-http/driver.ts +++ b/drizzle-orm/src/d1-http/driver.ts @@ -1,8 +1,8 @@ import type { BatchItem, BatchResponse } from '~/batch.ts'; import { entityKind } from '~/entity.ts'; import { DefaultLogger } from '~/logger.ts'; -import { createTableRelationsHelpers, extractTablesRelationalConfig } from '~/relations.ts'; import type { ExtractTablesWithRelations, RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; +import { createTableRelationsHelpers, extractTablesRelationalConfig } from '~/relations.ts'; import { BaseSQLiteDatabase } from '~/sqlite-core/db.ts'; import { SQLiteAsyncDialect } from '~/sqlite-core/dialect.ts'; import type { DrizzleConfig } from '~/utils.ts'; diff --git a/drizzle-orm/src/d1-http/index.ts b/drizzle-orm/src/d1-http/index.ts index 483c58446c..a6f3d2ecca 100644 --- a/drizzle-orm/src/d1-http/index.ts +++ b/drizzle-orm/src/d1-http/index.ts @@ -1,3 +1,3 @@ export * from './driver.ts'; -export * from './session.ts'; export * from './migrator.ts'; +export * from './session.ts'; diff --git a/drizzle-orm/src/d1-http/session.ts b/drizzle-orm/src/d1-http/session.ts index 4b9693f55a..796fc7a366 100644 --- a/drizzle-orm/src/d1-http/session.ts +++ b/drizzle-orm/src/d1-http/session.ts @@ -4,6 +4,20 @@ import type { WithCacheConfig } from '~/cache/core/types.ts'; import { entityKind } from '~/entity.ts'; import type { Logger } from '~/logger.ts'; import { NoopLogger } from '~/logger.ts'; +import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; +import type { PreparedQuery } from '~/session.ts'; +import { fillPlaceholders, type Query, sql } from '~/sql/sql.ts'; +import type { SQLiteAsyncDialect } from '~/sqlite-core/dialect.ts'; +import { SQLiteTransaction } from '~/sqlite-core/index.ts'; +import type { SelectedFieldsOrdered } from '~/sqlite-core/query-builders/select.types.ts'; +import type { + PreparedQueryConfig as PreparedQueryConfigBase, + SQLiteExecuteMethod, + SQLiteTransactionConfig, +} from '~/sqlite-core/session.ts'; +import { SQLitePreparedQuery, SQLiteSession } from '~/sqlite-core/session.ts'; +import { mapResultRow } from '~/utils.ts'; +import type { D1HttpCredentials, D1HttpResult } from './driver.ts'; // Define fetch function type to avoid dependency on @cloudflare/workers-types type FetchFunction = ( @@ -20,20 +34,6 @@ type FetchFunction = ( }>; const globalFetch = (globalThis as any).fetch as FetchFunction; -import type { RelationalSchemaConfig, TablesRelationalConfig } from '~/relations.ts'; -import type { PreparedQuery } from '~/session.ts'; -import { fillPlaceholders, type Query, sql } from '~/sql/sql.ts'; -import type { SQLiteAsyncDialect } from '~/sqlite-core/dialect.ts'; -import { SQLiteTransaction } from '~/sqlite-core/index.ts'; -import type { SelectedFieldsOrdered } from '~/sqlite-core/query-builders/select.types.ts'; -import type { - PreparedQueryConfig as PreparedQueryConfigBase, - SQLiteExecuteMethod, - SQLiteTransactionConfig, -} from '~/sqlite-core/session.ts'; -import { SQLitePreparedQuery, SQLiteSession } from '~/sqlite-core/session.ts'; -import { mapResultRow } from '~/utils.ts'; -import type { D1HttpCredentials, D1HttpResult } from './driver.ts'; export interface D1HttpSessionOptions { logger?: Logger; From a51178b5a918603b055c10131afb640ccac1ffe0 Mon Sep 17 00:00:00 2001 From: Zach Grimaldi Date: Mon, 25 Aug 2025 12:16:49 -0400 Subject: [PATCH 7/7] fix: tested batching and params limits --- drizzle-orm/src/d1-http/session.ts | 120 ++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/drizzle-orm/src/d1-http/session.ts b/drizzle-orm/src/d1-http/session.ts index 796fc7a366..e47e8a1d3a 100644 --- a/drizzle-orm/src/d1-http/session.ts +++ b/drizzle-orm/src/d1-http/session.ts @@ -121,7 +121,21 @@ export class D1HttpSession< } async batch[] | readonly BatchItem<'sqlite'>[]>(queries: T) { - // D1 batch API requires all queries in single SQL string, so we manually substitute params + // D1 batch limit: 300+ operations tested successfully, using 250 for safety margin + const MAX_BATCH_SIZE = 250; + const results: any[] = []; + + for (let i = 0; i < queries.length; i += MAX_BATCH_SIZE) { + const chunk = queries.slice(i, i + MAX_BATCH_SIZE); + const chunkResults = await this.executeBatchChunk(chunk); + results.push(...chunkResults); + } + + return results as any; + } + + private async executeBatchChunk[] | readonly BatchItem<'sqlite'>[]>(queries: T) { + // D1 batch API limitation: requires single SQL string with manual parameter substitution const preparedQueries: PreparedQuery[] = []; const builtQueries: { sql: string }[] = []; @@ -163,7 +177,6 @@ export class D1HttpSession< const data = (await response.json()) as D1ApiResponse; if (!data.success) { - // Enhanced error reporting with SQL context for debugging const errorMessage = data.errors.map(error => `${error.code}: ${error.message}`).join('\n'); throw new Error(`D1 Batch API Error: ${errorMessage}\nSQL: ${batchSql}`); } @@ -191,6 +204,14 @@ export class D1HttpSession< params: unknown[], method: 'run' | 'all' | 'values' | 'get' ): Promise { + // D1 parameter limit: 100 per query (Cloudflare docs + empirical testing) + // Ref: https://developers.cloudflare.com/d1/platform/limits/ + const D1_PARAMETER_LIMIT = 100; + + if (params.length > D1_PARAMETER_LIMIT && sql.toLowerCase().includes('insert into') && sql.includes('values')) { + return this.executeChunkedInsert(sql, params, method); + } + const { accountId, databaseId, token } = this.credentials; // Use /raw endpoint for values() method (returns arrays), /query for others (returns objects) @@ -209,7 +230,6 @@ export class D1HttpSession< const data = (await response.json()) as D1ApiResponse; if (!data.success) { - // Enhanced error reporting with SQL and params for debugging const errorMessage = data.errors.map(error => `${error.code}: ${error.message}`).join('\n'); throw new Error(`D1 API Error: ${errorMessage}\nSQL: ${sql}\nParams: ${JSON.stringify(params)}`); } @@ -224,6 +244,100 @@ export class D1HttpSession< return { rows }; } + private async executeChunkedInsert( + sql: string, + params: unknown[], + method: 'run' | 'all' | 'values' | 'get' + ): Promise { + // Multi-row INSERT chunking: preserves original SQL structure (nulls, defaults) + const insertMatch = sql.match(/insert\s+into\s+([^\s(]+)\s*\(([^)]+)\)\s+values\s+(.*)/i); + if (!insertMatch) { + return this.executeDirectQuery(sql, params, method); + } + + const tableName = insertMatch[1]!; + const columnsClause = insertMatch[2]!; + const valuesClause = insertMatch[3]!; + + // Extract single row pattern to preserve null/default handling + const singleRowMatch = valuesClause.match(/\([^)]+\)/); + if (!singleRowMatch) { + return this.executeDirectQuery(sql, params, method); + } + + const singleRowPattern = singleRowMatch[0]; + const paramCountPerRow = (singleRowPattern.match(/\?/g) || []).length; + + if (paramCountPerRow === 0) { + return this.executeDirectQuery(sql, params, method); + } + + const totalRows = Math.floor(params.length / paramCountPerRow); + const D1_PARAMETER_LIMIT = 100; + const MAX_ROWS_PER_CHUNK = Math.floor(D1_PARAMETER_LIMIT / paramCountPerRow); + + if (MAX_ROWS_PER_CHUNK <= 0) { + return this.executeDirectQuery(sql, params, method); + } + + let allRows: unknown[] = []; + + for (let i = 0; i < totalRows; i += MAX_ROWS_PER_CHUNK) { + const endRow = Math.min(i + MAX_ROWS_PER_CHUNK, totalRows); + const chunkParams = params.slice(i * paramCountPerRow, endRow * paramCountPerRow); + + const valuesPlaceholders = []; + for (let row = 0; row < endRow - i; row++) { + valuesPlaceholders.push(singleRowPattern); + } + + const chunkSql = `INSERT INTO ${tableName} (${columnsClause}) VALUES ${valuesPlaceholders.join(', ')}`; + + const chunkResult = await this.executeDirectQuery(chunkSql, chunkParams, method); + if (chunkResult.rows) { + allRows.push(...chunkResult.rows); + } + } + + return { rows: allRows }; + } + + private async executeDirectQuery( + sql: string, + params: unknown[], + method: 'run' | 'all' | 'values' | 'get' + ): Promise { + const { accountId, databaseId, token } = this.credentials; + + // Use /raw endpoint for values() method (returns arrays), /query for others (returns objects) + const endpoint = method === 'values' ? 'raw' : 'query'; + const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/${endpoint}`; + + const response = await globalFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ sql, params }), + }); + + const data = (await response.json()) as D1ApiResponse; + + if (!data.success) { + const errorMessage = data.errors.map(error => `${error.code}: ${error.message}`).join('\n'); + throw new Error(`D1 API Error: ${errorMessage}\nSQL: ${sql}\nParams: ${JSON.stringify(params)}`); + } + + const result = data.result[0]?.results; + if (!result) { + return { rows: [] }; + } + + const rows = Array.isArray(result) ? result : result.rows; + return { rows }; + } + override extractRawAllValueFromBatchResult(result: unknown): unknown { return (result as D1HttpResult).rows; }