Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/db-d1-sqlite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ npm install @payloadcms/db-d1-sqlite

## Usage

### Cloudflare Workers (D1 binding)

```ts
import { sqliteD1Adapter } from '@payloadcms/db-d1-sqlite'

Expand All @@ -24,10 +26,33 @@ export default buildConfig({
// Configure the D1 adapter here
db: sqliteD1Adapter({
// D1-specific arguments go here.
// `binding` is required and should match the D1 database binding name in your Cloudflare Worker environment.
// `binding` should match the D1 database binding in your Cloudflare Worker environment.
binding: cloudflare.env.D1,
}),
})
```

### HTTP (REST API — Vercel, Node, etc.)

Use the [D1 HTTP API](https://developers.cloudflare.com/d1/build-with-d1/d1-api/) when you do not have a D1 binding (for example on Vercel). Provide a Cloudflare API token with D1 permissions and your account and database IDs.

Expect higher latency than a Workers binding (one HTTP request per query, plus network). Read replica routing (`readReplicas`) is not available over the HTTP API; use a Cloudflare Workers deployment with a D1 binding if you need it.

```ts
import { sqliteD1Adapter } from '@payloadcms/db-d1-sqlite'

export default buildConfig({
collections: [
// Collections go here
],
db: sqliteD1Adapter({
http: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_API_TOKEN,
databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID,
},
}),
})
```

More detailed usage can be found in the [Payload Docs](https://payloadcms.com/docs/database/sqlite).
33 changes: 29 additions & 4 deletions packages/db-d1-sqlite/src/connect.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle'
import type { AnyD1Database } from 'drizzle-orm/d1'
import type { Connect, Migration } from 'payload'

import { pushDevSchema } from '@payloadcms/drizzle'
import { drizzle } from 'drizzle-orm/d1'

import type { SQLiteD1Adapter } from './types.js'

import { createD1LibsqlClientShim } from './d1-libsql-client-shim.js'
import { D1HttpBinding } from './http-binding/index.js'

export const connect: Connect = async function connect(
this: SQLiteD1Adapter,
options = {
Expand All @@ -23,19 +27,40 @@ export const connect: Connect = async function connect(
const logger = this.logger || false
const readReplicas = this.readReplicas

let binding = this.binding
let binding: AnyD1Database | undefined = this.binding
let httpBinding = false

if (!binding && this.httpConfig) {
binding = new D1HttpBinding(this.httpConfig) as unknown as AnyD1Database
this.binding = binding
httpBinding = true
}

if (!binding) {
throw new Error('db-d1-sqlite requires either a D1 `binding` or `http` config')
}

// `readReplicas` uses D1 `withSession` and only exists on a Workers binding — not on HTTP.
// sqliteD1Adapter rejects http + readReplicas; repeat here if adapter state was mutated.
if (httpBinding && readReplicas === 'first-primary') {
throw new Error(
'db-d1-sqlite: `readReplicas` is not supported with `http`. Use a Cloudflare Workers deployment with a D1 binding (this feature is not available over the HTTP API).',
)
}

if (readReplicas && readReplicas === 'first-primary') {
// @ts-expect-error - need to have types that support withSession binding from D1
binding = this.binding.withSession('first-primary')
}

this.drizzle = drizzle(binding, {
this.drizzle = drizzle(binding!, {
logger,
schema: this.schema,
})

this.client = this.drizzle.$client as any
this.client = httpBinding
? (createD1LibsqlClientShim(binding!) as SQLiteD1Adapter['client'])
: (this.drizzle.$client as SQLiteD1Adapter['client'])

if (!hotReload) {
if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
Expand All @@ -50,7 +75,7 @@ export const connect: Connect = async function connect(
if (typeof this.rejectInitializing === 'function') {
this.rejectInitializing()
}
console.error(err)

throw new Error(`Error: cannot connect to SQLite: ${message}`)
}

Expand Down
37 changes: 37 additions & 0 deletions packages/db-d1-sqlite/src/d1-libsql-client-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { AnyD1Database } from 'drizzle-orm/d1'

function getRowsFromD1AllResult(result: unknown): Record<string, unknown>[] {
if (Array.isArray(result)) {
return result as Record<string, unknown>[]
}

if (
result &&
typeof result === 'object' &&
'results' in result &&
Array.isArray((result as { results: unknown }).results)
) {
return (result as { results: Record<string, unknown>[] }).results
}

return []
}

/**
* `dropDatabase` in `@payloadcms/drizzle/sqlite` expects a libsql client (`execute`, `executeMultiple`).
* Cloudflare D1 (Workers binding and HTTP REST) exposes `prepare` / `exec` instead. This shim bridges the two.
*/
export function createD1LibsqlClientShim(client: AnyD1Database) {
return {
async execute(sql: string) {
const result = await client.prepare(sql).all()
const rows = getRowsFromD1AllResult(result)

return { rows }
},

async executeMultiple(sql: string) {
await client.exec(sql)
},
}
}
154 changes: 154 additions & 0 deletions packages/db-d1-sqlite/src/http-binding/d1-http-binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { HttpConfig } from '../types.js'
import type { D1HttpExecResult, D1HttpResult } from './d1-http-types.js'

import { D1HttpPreparedStatement } from './d1-http-prepared-statement.js'

type CloudflareD1QueryResponse<T> = {
errors?: unknown
messages?: unknown
result: T
success: boolean
}

function throwIfD1RowFailed(row: unknown, label: string): void {
if (
row &&
typeof row === 'object' &&
'success' in row &&
(row as { success?: boolean }).success === false
) {
const err =
(row as { error?: unknown; errors?: unknown }).error ?? (row as { errors?: unknown }).errors

throw new Error(`D1 HTTP ${label} failed${err !== undefined ? `: ${JSON.stringify(err)}` : ''}`)
}
}

export class D1HttpBinding {
private readonly config: {
baseUrl: string
} & Required<Pick<HttpConfig, 'accountId' | 'apiToken' | 'databaseId'>>

constructor(config: HttpConfig) {
this.config = {
...config,
baseUrl: config.baseUrl ?? 'https://api.cloudflare.com/client/v4',
}
}

private async request<T extends CloudflareD1QueryResponse<unknown>>(body: object): Promise<T> {
const response = await fetch(this.endpoint, {
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json',
},
method: 'POST',
})

const text = await response.text()
let data: unknown

try {
data = JSON.parse(text) as { errors?: unknown; success?: boolean } & T
} catch {
throw new Error(`D1 HTTP API error: ${response.status} ${text}`)
}

if (!response.ok) {
throw new Error(`D1 HTTP API error: ${response.status} ${text}`)
}

if (typeof data === 'object' && data !== null && 'success' in data && data.success === false) {
throw new Error(`D1 query failed: ${JSON.stringify((data as { errors?: unknown }).errors)}`)
}

return data as T
}

async batch<T = unknown>(statements: readonly unknown[]): Promise<D1HttpResult<T>[]> {
const batchPayload: { params: unknown[]; sql: string }[] = []

for (let i = 0; i < statements.length; i++) {
const stmt = statements[i]

if (!(stmt instanceof D1HttpPreparedStatement)) {
throw new Error(
`D1HttpBinding.batch: expected statements from this binding.prepare() at index ${i}`,
)
}

batchPayload.push({ params: stmt.params, sql: stmt.sql })
}

const response = await this.request<CloudflareD1QueryResponse<D1HttpResult<T>[]>>({
batch: batchPayload,
})

const { result } = response

if (result.length !== statements.length) {
throw new Error(
`D1 batch: expected ${String(statements.length)} result(s), got ${String(result.length)}`,
)
}

for (let i = 0; i < result.length; i++) {
throwIfD1RowFailed(result[i], `batch statement ${String(i)}`)
}

return result
}

dump(): Promise<ArrayBuffer> {
return Promise.reject(
new Error('D1 dump() is deprecated and not supported via the D1 HTTP API'),
)
}

async exec(query: string): Promise<D1HttpExecResult> {
const response = await this.request<CloudflareD1QueryResponse<D1HttpResult[]>>({ sql: query })

const first = response.result[0]

if (first) {
throwIfD1RowFailed(first, 'exec')
}

const meta = first?.meta

return {
count: meta?.changes ?? 0,
duration: meta?.duration ?? 0,
}
}

prepare(query: string): D1HttpPreparedStatement {
return new D1HttpPreparedStatement(this, query)
}

async query<T>(sql: string, params: unknown[]): Promise<D1HttpResult<T>> {
const response = await this.request<CloudflareD1QueryResponse<D1HttpResult<T>[]>>({
params,
sql,
})

const first = response.result[0]

if (!first) {
throw new Error('D1 HTTP API returned an empty result array')
}

throwIfD1RowFailed(first, 'query')

return first
}

withSession(): never {
throw new Error('D1 withSession() is not supported via the HTTP API')
}

private get endpoint(): string {
return `${this.config.baseUrl}/accounts/${this.config.accountId}/d1/database/${this.config.databaseId}/query`
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { D1HttpBinding } from './d1-http-binding.js'
import type { D1HttpResult } from './d1-http-types.js'

export class D1HttpPreparedStatement {
private readonly binding: D1HttpBinding
public params: unknown[] = []
public readonly sql: string

constructor(binding: D1HttpBinding, sql: string) {
this.binding = binding
this.sql = sql
}

async all<T = Record<string, unknown>>(): Promise<D1HttpResult<T>> {
return this.binding.query<T>(this.sql, this.params)
}

bind(...values: unknown[]): D1HttpPreparedStatement {
const stmt = new D1HttpPreparedStatement(this.binding, this.sql)

stmt.params = values

return stmt
}

async first<T = unknown>(colName?: string): Promise<null | T> {
const result = await this.binding.query<Record<string, unknown>>(this.sql, this.params)
const row = result.results[0]

if (!row) {
return null
}

if (colName !== undefined) {
return (row[colName] as T) ?? null
}

return row as T
}

async raw<T = unknown[]>(options?: { columnNames?: boolean }): Promise<[string[], ...T[]] | T[]> {
const result = await this.binding.query<Record<string, unknown>>(this.sql, this.params)

if (result.results.length === 0) {
return options?.columnNames ? ([[]] as [string[], ...T[]]) : ([] as T[])
}

const firstRow = result.results[0]

if (!firstRow) {
return options?.columnNames ? ([[]] as [string[], ...T[]]) : ([] as T[])
}

const columns = Object.keys(firstRow)
const rows = result.results.map((row) => columns.map((col) => row[col])) as T[]

if (options?.columnNames) {
return [columns, ...rows] as [string[], ...T[]]
}

return rows
}

async run<T = Record<string, unknown>>(): Promise<D1HttpResult<T>> {
return this.binding.query<T>(this.sql, this.params)
}
}
20 changes: 20 additions & 0 deletions packages/db-d1-sqlite/src/http-binding/d1-http-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Subset of Cloudflare D1 result types used by the HTTP shim. Kept local to avoid a
* dependency on `@cloudflare/workers-types` for consumers who only use HTTP mode.
*/
export type D1HttpMeta = {
[key: string]: unknown
changes?: number
duration: number
}

export type D1HttpResult<T = unknown> = {
meta: D1HttpMeta
results: T[]
success: true
}

export type D1HttpExecResult = {
count: number
duration: number
}
Loading
Loading