@onreza/prisma-adapter-bun 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +67 -0
- package/src/adapter.ts +256 -0
- package/src/conversion.ts +388 -0
- package/src/errors.ts +175 -0
- package/src/factory.ts +104 -0
- package/src/index.ts +3 -0
- package/src/types.ts +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ONREZA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# @onreza/prisma-adapter-bun
|
|
2
|
+
|
|
3
|
+
Prisma 7+ driver adapter for [Bun.sql](https://bun.com/docs/api/sql) — use Bun's built-in PostgreSQL client instead of `pg`.
|
|
4
|
+
|
|
5
|
+
- Zero external database dependencies — uses Bun's native PostgreSQL bindings
|
|
6
|
+
- Full Prisma compatibility — CRUD, relations, transactions, migrations, raw queries, TypedSQL
|
|
7
|
+
- Type-safe — ships as TypeScript source, ready to use with Bun
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @onreza/prisma-adapter-bun @prisma/driver-adapter-utils
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### 1. Prisma Schema
|
|
18
|
+
|
|
19
|
+
```prisma
|
|
20
|
+
// prisma/schema.prisma
|
|
21
|
+
datasource db {
|
|
22
|
+
provider = "postgresql"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
generator client {
|
|
26
|
+
provider = "prisma-client"
|
|
27
|
+
output = "./generated"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
model User {
|
|
31
|
+
id Int @id @default(autoincrement())
|
|
32
|
+
email String @unique
|
|
33
|
+
name String?
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Prisma Config (for migrations)
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// prisma.config.ts
|
|
41
|
+
import { defineConfig } from "prisma/config";
|
|
42
|
+
|
|
43
|
+
export default defineConfig({
|
|
44
|
+
schema: "prisma/schema.prisma",
|
|
45
|
+
datasource: {
|
|
46
|
+
url: process.env.DATABASE_URL!,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. Generate & Migrate
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bunx prisma generate
|
|
55
|
+
DATABASE_URL="postgres://user:pass@localhost:5432/mydb" bunx prisma db push
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 4. Use
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { PrismaClient } from "./prisma/generated/client";
|
|
62
|
+
import { PrismaBun } from "@onreza/prisma-adapter-bun";
|
|
63
|
+
|
|
64
|
+
const adapter = new PrismaBun("postgres://user:pass@localhost:5432/mydb");
|
|
65
|
+
const prisma = new PrismaClient({ adapter });
|
|
66
|
+
|
|
67
|
+
const users = await prisma.user.findMany();
|
|
68
|
+
console.log(users);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
### Connection String
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const adapter = new PrismaBun("postgres://user:pass@localhost:5432/mydb");
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Connection Options
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { SQL } from "bun";
|
|
83
|
+
|
|
84
|
+
const adapter = new PrismaBun({
|
|
85
|
+
hostname: "localhost",
|
|
86
|
+
port: 5432,
|
|
87
|
+
database: "mydb",
|
|
88
|
+
username: "user",
|
|
89
|
+
password: "pass",
|
|
90
|
+
tls: true,
|
|
91
|
+
} satisfies SQL.PostgresOrMySQLOptions);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Existing Bun.sql Client
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { SQL } from "bun";
|
|
98
|
+
|
|
99
|
+
const client = new SQL("postgres://user:pass@localhost:5432/mydb");
|
|
100
|
+
const adapter = new PrismaBun(client);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Schema Option
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const adapter = new PrismaBun("postgres://...", { schema: "my_schema" });
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## API
|
|
110
|
+
|
|
111
|
+
### `PrismaBun` (factory)
|
|
112
|
+
|
|
113
|
+
The main export. Implements Prisma's `SqlMigrationAwareDriverAdapterFactory`.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
new PrismaBun(config: string | URL | SQL.PostgresOrMySQLOptions, options?: PrismaBunOptions)
|
|
117
|
+
new PrismaBun(client: SQL, options?: PrismaBunOptions)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Options:**
|
|
121
|
+
|
|
122
|
+
| Option | Type | Default | Description |
|
|
123
|
+
|----------|----------|------------|-------------------------|
|
|
124
|
+
| `schema` | `string` | `"public"` | PostgreSQL search path |
|
|
125
|
+
|
|
126
|
+
**Methods:**
|
|
127
|
+
|
|
128
|
+
| Method | Description |
|
|
129
|
+
|---------------------|-----------------------------------------------------|
|
|
130
|
+
| `connect()` | Creates a new connection and returns an adapter |
|
|
131
|
+
| `connectToShadowDb()` | Creates a temporary database for Prisma Migrate |
|
|
132
|
+
|
|
133
|
+
### `PrismaBunAdapter`
|
|
134
|
+
|
|
135
|
+
Low-level adapter for direct use (without factory). Implements `SqlDriverAdapter`.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { SQL } from "bun";
|
|
139
|
+
import { PrismaBunAdapter } from "@onreza/prisma-adapter-bun";
|
|
140
|
+
|
|
141
|
+
const client = new SQL("postgres://...");
|
|
142
|
+
const adapter = new PrismaBunAdapter(client);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Supported Features
|
|
146
|
+
|
|
147
|
+
| Feature | Status |
|
|
148
|
+
|-----------------------------|--------|
|
|
149
|
+
| CRUD (create, read, update, delete) | Supported |
|
|
150
|
+
| Relations (1:1, 1:N, M:N) | Supported |
|
|
151
|
+
| Interactive transactions | Supported |
|
|
152
|
+
| Batch transactions | Supported |
|
|
153
|
+
| Isolation levels | Supported |
|
|
154
|
+
| `$queryRaw` / `$executeRaw`| Supported |
|
|
155
|
+
| TypedSQL | Supported |
|
|
156
|
+
| Prisma Migrate | Supported |
|
|
157
|
+
| Shadow database | Supported |
|
|
158
|
+
| All PostgreSQL scalar types | Supported |
|
|
159
|
+
| Array types (`String[]`, etc.) | Supported |
|
|
160
|
+
| Json / Bytes / Decimal / BigInt | Supported |
|
|
161
|
+
| Enums | Supported |
|
|
162
|
+
| Connection pooling | Built-in (Bun.sql) |
|
|
163
|
+
|
|
164
|
+
## Requirements
|
|
165
|
+
|
|
166
|
+
- **Bun** >= 1.2.0 (for `Bun.sql` support)
|
|
167
|
+
- **PostgreSQL** >= 12
|
|
168
|
+
- **Prisma** >= 7.0.0
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# Install dependencies
|
|
174
|
+
bun install
|
|
175
|
+
|
|
176
|
+
# Run unit tests (no database needed)
|
|
177
|
+
bun test tests/conversion.test.ts tests/errors.test.ts
|
|
178
|
+
|
|
179
|
+
# Run integration tests (needs PostgreSQL)
|
|
180
|
+
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" bun test tests/adapter.test.ts
|
|
181
|
+
|
|
182
|
+
# Run e2e tests (needs Docker)
|
|
183
|
+
bun test --timeout 120000 tests/factory.test.ts tests/prisma.test.ts
|
|
184
|
+
|
|
185
|
+
# Run all tests
|
|
186
|
+
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" bun test --timeout 120000
|
|
187
|
+
|
|
188
|
+
# Type check
|
|
189
|
+
bunx tsc --noEmit
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## How It Works
|
|
193
|
+
|
|
194
|
+
This adapter bridges Prisma's driver adapter protocol with Bun's built-in `Bun.sql` PostgreSQL client:
|
|
195
|
+
|
|
196
|
+
1. **Queries** — Prisma generates parameterized SQL (`$1`, `$2`, ...). The adapter passes them to `Bun.sql.unsafe(sql, values)`, which binds parameters at the PostgreSQL wire protocol level.
|
|
197
|
+
|
|
198
|
+
2. **Type Mapping** — PostgreSQL column types are mapped to Prisma's `ColumnType` enum. Since Bun.sql doesn't yet expose column OID metadata, types are inferred from JavaScript values. When Bun adds `.columns` support, the adapter will automatically use native OIDs.
|
|
199
|
+
|
|
200
|
+
3. **Transactions** — Uses `Bun.sql.reserve()` to get a dedicated connection, then sends `BEGIN`/`COMMIT`/`ROLLBACK` as Prisma expects.
|
|
201
|
+
|
|
202
|
+
4. **Migrations** — The factory's `connectToShadowDb()` creates a temporary database for Prisma Migrate, and cleans it up on dispose.
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onreza/prisma-adapter-bun",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Prisma 7+ driver adapter for Bun.sql — use Bun's built-in PostgreSQL client instead of pg",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"module": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ONREZA/prisma-adapter-bun.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/ONREZA/prisma-adapter-bun/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/ONREZA/prisma-adapter-bun#readme",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"prisma",
|
|
26
|
+
"bun",
|
|
27
|
+
"bun-sql",
|
|
28
|
+
"postgresql",
|
|
29
|
+
"postgres",
|
|
30
|
+
"driver-adapter",
|
|
31
|
+
"database",
|
|
32
|
+
"orm"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"bun": ">=1.2.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@prisma/driver-adapter-utils": "^7.3.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@biomejs/biome": "^2.3.14",
|
|
42
|
+
"@commitlint/types": "^20.4.0",
|
|
43
|
+
"@prisma/client": "^7.3.0",
|
|
44
|
+
"@types/bun": "latest",
|
|
45
|
+
"lefthook": "^2.1.0",
|
|
46
|
+
"onreza-release": "^2.3.0",
|
|
47
|
+
"prisma": "^7.3.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"typescript": "^5"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"lint": "biome check",
|
|
54
|
+
"lint:fix": "biome check --write",
|
|
55
|
+
"format": "biome format --write",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"check": "biome check && tsc --noEmit",
|
|
58
|
+
"test": "bun test tests/conversion.test.ts tests/errors.test.ts",
|
|
59
|
+
"test:integration": "bun test tests/adapter.test.ts",
|
|
60
|
+
"test:e2e": "bun test --timeout 120000 tests/factory.test.ts tests/prisma.test.ts",
|
|
61
|
+
"test:all": "bun test --timeout 120000",
|
|
62
|
+
"prepublishOnly": "bunx tsc --noEmit",
|
|
63
|
+
"release": "onreza-release",
|
|
64
|
+
"release:dry": "onreza-release --dry-run",
|
|
65
|
+
"prepare": "lefthook install || true"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ColumnType,
|
|
3
|
+
type ConnectionInfo,
|
|
4
|
+
DriverAdapterError,
|
|
5
|
+
type IsolationLevel,
|
|
6
|
+
type SqlDriverAdapter,
|
|
7
|
+
type SqlQuery,
|
|
8
|
+
type SqlQueryable,
|
|
9
|
+
type SqlResultSet,
|
|
10
|
+
type Transaction,
|
|
11
|
+
type TransactionOptions,
|
|
12
|
+
} from "@prisma/driver-adapter-utils";
|
|
13
|
+
import type { SQL as BunSQL, ReservedSQL } from "bun";
|
|
14
|
+
import {
|
|
15
|
+
fieldToColumnType,
|
|
16
|
+
findFirstNonNullInColumn,
|
|
17
|
+
inferOidFromValue,
|
|
18
|
+
mapArg,
|
|
19
|
+
resultNormalizers,
|
|
20
|
+
UnsupportedNativeDataType,
|
|
21
|
+
} from "./conversion.ts";
|
|
22
|
+
import { convertDriverError } from "./errors.ts";
|
|
23
|
+
import type { ColumnMetadata, PrismaBunOptions } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
const ADAPTER_NAME = "@onreza/prisma-adapter-bun";
|
|
26
|
+
|
|
27
|
+
const VALID_ISOLATION_LEVELS = new Set(["READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"]);
|
|
28
|
+
|
|
29
|
+
type QueryResult = {
|
|
30
|
+
columns: ColumnMetadata[];
|
|
31
|
+
rows: unknown[][];
|
|
32
|
+
rowCount: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
class BunQueryable implements SqlQueryable {
|
|
36
|
+
readonly provider = "postgres" as const;
|
|
37
|
+
readonly adapterName = ADAPTER_NAME;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
protected readonly client: BunSQL | ReservedSQL,
|
|
41
|
+
protected readonly bunOptions?: PrismaBunOptions,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
protected async performIO(query: SqlQuery): Promise<QueryResult> {
|
|
45
|
+
const { sql, args, argTypes } = query;
|
|
46
|
+
const values = args.map((arg, i) => {
|
|
47
|
+
const argType = argTypes[i];
|
|
48
|
+
if (!argType) return arg;
|
|
49
|
+
return mapArg(arg, argType);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Execute query in regular (object) mode
|
|
54
|
+
const result = await this.client.unsafe(sql, values);
|
|
55
|
+
const resultRecord = result as unknown as Record<string, unknown>;
|
|
56
|
+
const rowCount: number = (resultRecord.count as number) ?? result.length;
|
|
57
|
+
|
|
58
|
+
// Primary path: use .columns metadata if Bun.sql exposes it
|
|
59
|
+
const rawColumns = resultRecord.columns;
|
|
60
|
+
if (Array.isArray(rawColumns) && rawColumns.length > 0) {
|
|
61
|
+
const columns: ColumnMetadata[] = rawColumns.map((c: Record<string, unknown>) => ({
|
|
62
|
+
name: String(c.name),
|
|
63
|
+
type: Number(c.type),
|
|
64
|
+
}));
|
|
65
|
+
// Convert object rows to array rows using column order from metadata
|
|
66
|
+
const objectRows = result as unknown as Record<string, unknown>[];
|
|
67
|
+
const rows: unknown[][] = objectRows.map((row) => columns.map((col) => row[col.name]));
|
|
68
|
+
return { columns, rowCount, rows };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fallback: extract column names from object keys, infer types from values
|
|
72
|
+
if (result.length === 0) {
|
|
73
|
+
return { columns: [], rowCount, rows: [] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const firstRow = result[0] as Record<string, unknown>;
|
|
77
|
+
const columnNames = Object.keys(firstRow);
|
|
78
|
+
|
|
79
|
+
// Convert object rows to array rows
|
|
80
|
+
const objectRows = result as unknown as Record<string, unknown>[];
|
|
81
|
+
const rows: unknown[][] = objectRows.map((row) => columnNames.map((name) => row[name]));
|
|
82
|
+
|
|
83
|
+
// Infer OID for each column from first non-null value
|
|
84
|
+
const columns: ColumnMetadata[] = columnNames.map((name, i) => ({
|
|
85
|
+
name,
|
|
86
|
+
type: inferOidFromValue(findFirstNonNullInColumn(rows, i)),
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
return { columns, rowCount, rows };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
throw new DriverAdapterError(convertDriverError(e));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async queryRaw(query: SqlQuery): Promise<SqlResultSet> {
|
|
96
|
+
const { columns, rows } = await this.performIO(query);
|
|
97
|
+
|
|
98
|
+
const columnNames = columns.map((col) => col.name);
|
|
99
|
+
const columnTypes = this.mapColumnTypes(columns);
|
|
100
|
+
this.normalizeRows(columns, rows);
|
|
101
|
+
|
|
102
|
+
return { columnNames, columnTypes, rows };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private mapColumnTypes(columns: ColumnMetadata[]): ColumnType[] {
|
|
106
|
+
try {
|
|
107
|
+
return columns.map((col) => fieldToColumnType(col.type));
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e instanceof UnsupportedNativeDataType) {
|
|
110
|
+
throw new DriverAdapterError({
|
|
111
|
+
kind: "UnsupportedNativeDataType",
|
|
112
|
+
type: e.type,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private normalizeRows(columns: ColumnMetadata[], rows: unknown[][]): void {
|
|
120
|
+
for (let i = 0; i < columns.length; i++) {
|
|
121
|
+
const col = columns[i];
|
|
122
|
+
if (!col) continue;
|
|
123
|
+
const normalizer = resultNormalizers[col.type];
|
|
124
|
+
if (normalizer) {
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
if (row[i] !== null && row[i] !== undefined) {
|
|
127
|
+
row[i] = normalizer(row[i]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async executeRaw(query: SqlQuery): Promise<number> {
|
|
135
|
+
const { rowCount } = await this.performIO(query);
|
|
136
|
+
return rowCount;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class BunTransaction extends BunQueryable implements Transaction {
|
|
141
|
+
readonly txOptions: TransactionOptions = { usePhantomQuery: false };
|
|
142
|
+
private released = false;
|
|
143
|
+
|
|
144
|
+
constructor(
|
|
145
|
+
protected override readonly client: ReservedSQL,
|
|
146
|
+
bunOptions?: PrismaBunOptions,
|
|
147
|
+
) {
|
|
148
|
+
super(client, bunOptions);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get options(): TransactionOptions {
|
|
152
|
+
return this.txOptions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private release(): void {
|
|
156
|
+
if (!this.released) {
|
|
157
|
+
this.released = true;
|
|
158
|
+
this.client.release();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
commit(): Promise<void> {
|
|
163
|
+
// Prisma Engine sends COMMIT via executeRaw before calling commit().
|
|
164
|
+
// We only release the reserved connection back to the pool.
|
|
165
|
+
this.release();
|
|
166
|
+
return Promise.resolve();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
rollback(): Promise<void> {
|
|
170
|
+
// Prisma Engine sends ROLLBACK via executeRaw before calling rollback().
|
|
171
|
+
// We only release the reserved connection.
|
|
172
|
+
this.release();
|
|
173
|
+
return Promise.resolve();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class PrismaBunAdapter extends BunQueryable implements SqlDriverAdapter {
|
|
178
|
+
private readonly disposeCallback?: () => Promise<void>;
|
|
179
|
+
|
|
180
|
+
constructor(
|
|
181
|
+
protected override readonly client: BunSQL,
|
|
182
|
+
bunOptions?: PrismaBunOptions,
|
|
183
|
+
disposeCallback?: () => Promise<void>,
|
|
184
|
+
) {
|
|
185
|
+
super(client, bunOptions);
|
|
186
|
+
this.disposeCallback = disposeCallback;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async startTransaction(isolationLevel?: IsolationLevel): Promise<Transaction> {
|
|
190
|
+
if (isolationLevel && !VALID_ISOLATION_LEVELS.has(isolationLevel.toUpperCase())) {
|
|
191
|
+
throw new DriverAdapterError({
|
|
192
|
+
code: "INVALID_ISOLATION_LEVEL",
|
|
193
|
+
column: undefined,
|
|
194
|
+
detail: undefined,
|
|
195
|
+
hint: undefined,
|
|
196
|
+
kind: "postgres",
|
|
197
|
+
message: `Invalid isolation level: ${isolationLevel}`,
|
|
198
|
+
severity: "ERROR",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const reserved = await this.client.reserve();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const tx = new BunTransaction(reserved, this.bunOptions);
|
|
206
|
+
|
|
207
|
+
await tx.executeRaw({ args: [], argTypes: [], sql: "BEGIN" });
|
|
208
|
+
|
|
209
|
+
if (isolationLevel) {
|
|
210
|
+
await tx.executeRaw({
|
|
211
|
+
args: [],
|
|
212
|
+
argTypes: [],
|
|
213
|
+
sql: `SET TRANSACTION ISOLATION LEVEL ${isolationLevel.toUpperCase()}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return tx;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
try {
|
|
220
|
+
await reserved.unsafe("ROLLBACK");
|
|
221
|
+
} catch {
|
|
222
|
+
// Ignore rollback errors during cleanup
|
|
223
|
+
}
|
|
224
|
+
reserved.release();
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async executeScript(script: string): Promise<void> {
|
|
230
|
+
try {
|
|
231
|
+
await this.client.unsafe(script).simple();
|
|
232
|
+
} catch (e) {
|
|
233
|
+
throw new DriverAdapterError(convertDriverError(e));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
getConnectionInfo(): ConnectionInfo {
|
|
238
|
+
return {
|
|
239
|
+
maxBindValues: 65535,
|
|
240
|
+
schemaName: this.bunOptions?.schema ?? "public",
|
|
241
|
+
supportsRelationJoins: true,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async dispose(): Promise<void> {
|
|
246
|
+
try {
|
|
247
|
+
if (this.disposeCallback) {
|
|
248
|
+
await this.disposeCallback();
|
|
249
|
+
} else {
|
|
250
|
+
await this.client.close();
|
|
251
|
+
}
|
|
252
|
+
} catch (e) {
|
|
253
|
+
throw new DriverAdapterError(convertDriverError(e));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { type ArgType, type ColumnType, ColumnTypeEnum } from "@prisma/driver-adapter-utils";
|
|
2
|
+
|
|
3
|
+
// Top-level regex constants (biome: useTopLevelRegex)
|
|
4
|
+
const RE_TIMESTAMPTZ_OFFSET = /([+-]\d{2})$/;
|
|
5
|
+
const RE_TIMETZ_STRIP = /[+-]\d{2}(:\d{2})?$/;
|
|
6
|
+
const RE_MONEY_SYMBOL = /^\$/;
|
|
7
|
+
const RE_PG_ESCAPE_BACKSLASH = /\\/g;
|
|
8
|
+
const RE_PG_ESCAPE_QUOTE = /"/g;
|
|
9
|
+
|
|
10
|
+
// PostgreSQL OIDs (from pg_type system catalog)
|
|
11
|
+
export const PgOid = {
|
|
12
|
+
BIT: 1560,
|
|
13
|
+
BIT_ARRAY: 1561,
|
|
14
|
+
BOOL: 16,
|
|
15
|
+
|
|
16
|
+
// Array types
|
|
17
|
+
BOOL_ARRAY: 1000,
|
|
18
|
+
BPCHAR: 1042,
|
|
19
|
+
BPCHAR_ARRAY: 1014,
|
|
20
|
+
BYTEA: 17,
|
|
21
|
+
BYTEA_ARRAY: 1001,
|
|
22
|
+
CHAR: 18,
|
|
23
|
+
CHAR_ARRAY: 1002,
|
|
24
|
+
CIDR: 650,
|
|
25
|
+
CIDR_ARRAY: 651,
|
|
26
|
+
DATE: 1082,
|
|
27
|
+
DATE_ARRAY: 1182,
|
|
28
|
+
FLOAT4: 700,
|
|
29
|
+
FLOAT4_ARRAY: 1021,
|
|
30
|
+
FLOAT8: 701,
|
|
31
|
+
FLOAT8_ARRAY: 1022,
|
|
32
|
+
INET: 869,
|
|
33
|
+
INET_ARRAY: 1041,
|
|
34
|
+
INT2: 21,
|
|
35
|
+
INT2_ARRAY: 1005,
|
|
36
|
+
INT4: 23,
|
|
37
|
+
INT4_ARRAY: 1007,
|
|
38
|
+
INT8: 20,
|
|
39
|
+
INT8_ARRAY: 1016,
|
|
40
|
+
JSON: 114,
|
|
41
|
+
JSON_ARRAY: 199,
|
|
42
|
+
JSONB: 3802,
|
|
43
|
+
JSONB_ARRAY: 3807,
|
|
44
|
+
MONEY: 790,
|
|
45
|
+
MONEY_ARRAY: 791,
|
|
46
|
+
NAME: 19,
|
|
47
|
+
NAME_ARRAY: 1003,
|
|
48
|
+
NUMERIC: 1700,
|
|
49
|
+
NUMERIC_ARRAY: 1231,
|
|
50
|
+
OID: 26,
|
|
51
|
+
OID_ARRAY: 1028,
|
|
52
|
+
TEXT: 25,
|
|
53
|
+
TEXT_ARRAY: 1009,
|
|
54
|
+
TIME: 1083,
|
|
55
|
+
TIME_ARRAY: 1183,
|
|
56
|
+
TIMESTAMP: 1114,
|
|
57
|
+
TIMESTAMP_ARRAY: 1115,
|
|
58
|
+
TIMESTAMPTZ: 1184,
|
|
59
|
+
TIMESTAMPTZ_ARRAY: 1185,
|
|
60
|
+
TIMETZ: 1266,
|
|
61
|
+
TIMETZ_ARRAY: 1270,
|
|
62
|
+
UUID: 2950,
|
|
63
|
+
UUID_ARRAY: 2951,
|
|
64
|
+
VARBIT: 1562,
|
|
65
|
+
VARBIT_ARRAY: 1563,
|
|
66
|
+
VARCHAR: 1043,
|
|
67
|
+
VARCHAR_ARRAY: 1015,
|
|
68
|
+
XML: 142,
|
|
69
|
+
XML_ARRAY: 143,
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
// OID threshold: types >= this are user-defined (enums, composites, etc.)
|
|
73
|
+
const FIRST_NORMAL_OBJECT_ID = 16384;
|
|
74
|
+
|
|
75
|
+
export class UnsupportedNativeDataType extends Error {
|
|
76
|
+
type: string;
|
|
77
|
+
constructor(oid: number) {
|
|
78
|
+
const message = `Unsupported column type with OID ${oid}`;
|
|
79
|
+
super(message);
|
|
80
|
+
this.type = `${oid}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const scalarMapping: Record<number, ColumnType> = {
|
|
85
|
+
[PgOid.INT2]: ColumnTypeEnum.Int32,
|
|
86
|
+
[PgOid.INT4]: ColumnTypeEnum.Int32,
|
|
87
|
+
[PgOid.INT8]: ColumnTypeEnum.Int64,
|
|
88
|
+
[PgOid.FLOAT4]: ColumnTypeEnum.Float,
|
|
89
|
+
[PgOid.FLOAT8]: ColumnTypeEnum.Double,
|
|
90
|
+
[PgOid.BOOL]: ColumnTypeEnum.Boolean,
|
|
91
|
+
[PgOid.DATE]: ColumnTypeEnum.Date,
|
|
92
|
+
[PgOid.TIME]: ColumnTypeEnum.Time,
|
|
93
|
+
[PgOid.TIMETZ]: ColumnTypeEnum.Time,
|
|
94
|
+
[PgOid.TIMESTAMP]: ColumnTypeEnum.DateTime,
|
|
95
|
+
[PgOid.TIMESTAMPTZ]: ColumnTypeEnum.DateTime,
|
|
96
|
+
[PgOid.NUMERIC]: ColumnTypeEnum.Numeric,
|
|
97
|
+
[PgOid.MONEY]: ColumnTypeEnum.Numeric,
|
|
98
|
+
[PgOid.JSON]: ColumnTypeEnum.Json,
|
|
99
|
+
[PgOid.JSONB]: ColumnTypeEnum.Json,
|
|
100
|
+
[PgOid.UUID]: ColumnTypeEnum.Uuid,
|
|
101
|
+
[PgOid.OID]: ColumnTypeEnum.Int64,
|
|
102
|
+
[PgOid.BPCHAR]: ColumnTypeEnum.Text,
|
|
103
|
+
[PgOid.TEXT]: ColumnTypeEnum.Text,
|
|
104
|
+
[PgOid.VARCHAR]: ColumnTypeEnum.Text,
|
|
105
|
+
[PgOid.BIT]: ColumnTypeEnum.Text,
|
|
106
|
+
[PgOid.VARBIT]: ColumnTypeEnum.Text,
|
|
107
|
+
[PgOid.INET]: ColumnTypeEnum.Text,
|
|
108
|
+
[PgOid.CIDR]: ColumnTypeEnum.Text,
|
|
109
|
+
[PgOid.XML]: ColumnTypeEnum.Text,
|
|
110
|
+
[PgOid.NAME]: ColumnTypeEnum.Text,
|
|
111
|
+
[PgOid.CHAR]: ColumnTypeEnum.Character,
|
|
112
|
+
[PgOid.BYTEA]: ColumnTypeEnum.Bytes,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const arrayMapping: Record<number, ColumnType> = {
|
|
116
|
+
[PgOid.INT2_ARRAY]: ColumnTypeEnum.Int32Array,
|
|
117
|
+
[PgOid.INT4_ARRAY]: ColumnTypeEnum.Int32Array,
|
|
118
|
+
[PgOid.INT8_ARRAY]: ColumnTypeEnum.Int64Array,
|
|
119
|
+
[PgOid.FLOAT4_ARRAY]: ColumnTypeEnum.FloatArray,
|
|
120
|
+
[PgOid.FLOAT8_ARRAY]: ColumnTypeEnum.DoubleArray,
|
|
121
|
+
[PgOid.BOOL_ARRAY]: ColumnTypeEnum.BooleanArray,
|
|
122
|
+
[PgOid.DATE_ARRAY]: ColumnTypeEnum.DateArray,
|
|
123
|
+
[PgOid.TIME_ARRAY]: ColumnTypeEnum.TimeArray,
|
|
124
|
+
[PgOid.TIMETZ_ARRAY]: ColumnTypeEnum.TimeArray,
|
|
125
|
+
[PgOid.TIMESTAMP_ARRAY]: ColumnTypeEnum.DateTimeArray,
|
|
126
|
+
[PgOid.TIMESTAMPTZ_ARRAY]: ColumnTypeEnum.DateTimeArray,
|
|
127
|
+
[PgOid.NUMERIC_ARRAY]: ColumnTypeEnum.NumericArray,
|
|
128
|
+
[PgOid.MONEY_ARRAY]: ColumnTypeEnum.NumericArray,
|
|
129
|
+
[PgOid.JSON_ARRAY]: ColumnTypeEnum.JsonArray,
|
|
130
|
+
[PgOid.JSONB_ARRAY]: ColumnTypeEnum.JsonArray,
|
|
131
|
+
[PgOid.UUID_ARRAY]: ColumnTypeEnum.UuidArray,
|
|
132
|
+
[PgOid.OID_ARRAY]: ColumnTypeEnum.Int64Array,
|
|
133
|
+
[PgOid.BPCHAR_ARRAY]: ColumnTypeEnum.TextArray,
|
|
134
|
+
[PgOid.TEXT_ARRAY]: ColumnTypeEnum.TextArray,
|
|
135
|
+
[PgOid.VARCHAR_ARRAY]: ColumnTypeEnum.TextArray,
|
|
136
|
+
[PgOid.BIT_ARRAY]: ColumnTypeEnum.TextArray,
|
|
137
|
+
[PgOid.VARBIT_ARRAY]: ColumnTypeEnum.TextArray,
|
|
138
|
+
[PgOid.INET_ARRAY]: ColumnTypeEnum.TextArray,
|
|
139
|
+
[PgOid.CIDR_ARRAY]: ColumnTypeEnum.TextArray,
|
|
140
|
+
[PgOid.XML_ARRAY]: ColumnTypeEnum.TextArray,
|
|
141
|
+
[PgOid.NAME_ARRAY]: ColumnTypeEnum.TextArray,
|
|
142
|
+
[PgOid.CHAR_ARRAY]: ColumnTypeEnum.CharacterArray,
|
|
143
|
+
[PgOid.BYTEA_ARRAY]: ColumnTypeEnum.BytesArray,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export function fieldToColumnType(oid: number): ColumnType {
|
|
147
|
+
const scalar = scalarMapping[oid];
|
|
148
|
+
if (scalar !== undefined) return scalar;
|
|
149
|
+
|
|
150
|
+
const array = arrayMapping[oid];
|
|
151
|
+
if (array !== undefined) return array;
|
|
152
|
+
|
|
153
|
+
// User-defined types (enums, composites) are treated as Text
|
|
154
|
+
if (oid >= FIRST_NORMAL_OBJECT_ID) return ColumnTypeEnum.Text;
|
|
155
|
+
|
|
156
|
+
throw new UnsupportedNativeDataType(oid);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Value normalizers (post-processing Bun.sql results) ---
|
|
160
|
+
|
|
161
|
+
function pad2(n: number): string {
|
|
162
|
+
return n < 10 ? `0${n}` : `${n}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function pad3(n: number): string {
|
|
166
|
+
if (n < 10) return `00${n}`;
|
|
167
|
+
if (n < 100) return `0${n}`;
|
|
168
|
+
return `${n}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatDateTime(date: Date): string {
|
|
172
|
+
return (
|
|
173
|
+
`${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` +
|
|
174
|
+
`${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.${pad3(date.getUTCMilliseconds())}`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatDate(date: Date): string {
|
|
179
|
+
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatTime(date: Date): string {
|
|
183
|
+
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}.${pad3(date.getUTCMilliseconds())}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Normalize a timestamp string to ISO-like format: "2024-01-01 12:00:00" -> "2024-01-01T12:00:00.000+00:00"
|
|
187
|
+
function normalizeTimestamp(value: unknown): unknown {
|
|
188
|
+
if (value instanceof Date) {
|
|
189
|
+
return `${formatDateTime(value)}+00:00`;
|
|
190
|
+
}
|
|
191
|
+
if (typeof value === "string") {
|
|
192
|
+
return `${value.replace(" ", "T")}+00:00`;
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeTimestamptz(value: unknown): unknown {
|
|
198
|
+
if (value instanceof Date) {
|
|
199
|
+
return `${formatDateTime(value)}+00:00`;
|
|
200
|
+
}
|
|
201
|
+
if (typeof value === "string") {
|
|
202
|
+
// Normalize various timezone formats to +00:00
|
|
203
|
+
return value.replace(" ", "T").replace(RE_TIMESTAMPTZ_OFFSET, "$1:00");
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeTimetz(value: unknown): unknown {
|
|
209
|
+
if (typeof value === "string") {
|
|
210
|
+
// Remove timezone offset from time value
|
|
211
|
+
return value.replace(RE_TIMETZ_STRIP, "");
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeDate(value: unknown): unknown {
|
|
217
|
+
if (value instanceof Date) {
|
|
218
|
+
return formatDate(value);
|
|
219
|
+
}
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeTime(value: unknown): unknown {
|
|
224
|
+
if (value instanceof Date) {
|
|
225
|
+
return formatTime(value);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeNumeric(value: unknown): unknown {
|
|
231
|
+
return String(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeMoney(value: unknown): unknown {
|
|
235
|
+
const s = String(value);
|
|
236
|
+
return s.replace(RE_MONEY_SYMBOL, "");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeJson(value: unknown): unknown {
|
|
240
|
+
if (typeof value === "object" && value !== null) {
|
|
241
|
+
return JSON.stringify(value);
|
|
242
|
+
}
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeBytes(value: unknown): unknown {
|
|
247
|
+
if (value instanceof Uint8Array || value instanceof Buffer) {
|
|
248
|
+
return Buffer.from(value);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeArray(value: unknown, elementNormalizer: (v: unknown) => unknown): unknown {
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
return value.map((el) => (el === null ? null : elementNormalizer(el)));
|
|
256
|
+
}
|
|
257
|
+
return value;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
type Normalizer = (value: unknown) => unknown;
|
|
261
|
+
|
|
262
|
+
export const resultNormalizers: Record<number, Normalizer> = {
|
|
263
|
+
[PgOid.NUMERIC]: normalizeNumeric,
|
|
264
|
+
[PgOid.MONEY]: normalizeMoney,
|
|
265
|
+
[PgOid.TIME]: normalizeTime,
|
|
266
|
+
[PgOid.TIMETZ]: normalizeTimetz,
|
|
267
|
+
[PgOid.DATE]: normalizeDate,
|
|
268
|
+
[PgOid.TIMESTAMP]: normalizeTimestamp,
|
|
269
|
+
[PgOid.TIMESTAMPTZ]: normalizeTimestamptz,
|
|
270
|
+
[PgOid.JSON]: normalizeJson,
|
|
271
|
+
[PgOid.JSONB]: normalizeJson,
|
|
272
|
+
[PgOid.BYTEA]: normalizeBytes,
|
|
273
|
+
|
|
274
|
+
// Array normalizers
|
|
275
|
+
[PgOid.NUMERIC_ARRAY]: (v) => normalizeArray(v, normalizeNumeric),
|
|
276
|
+
[PgOid.MONEY_ARRAY]: (v) => normalizeArray(v, normalizeMoney),
|
|
277
|
+
[PgOid.TIME_ARRAY]: (v) => normalizeArray(v, normalizeTime),
|
|
278
|
+
[PgOid.TIMETZ_ARRAY]: (v) => normalizeArray(v, normalizeTimetz),
|
|
279
|
+
[PgOid.DATE_ARRAY]: (v) => normalizeArray(v, normalizeDate),
|
|
280
|
+
[PgOid.TIMESTAMP_ARRAY]: (v) => normalizeArray(v, normalizeTimestamp),
|
|
281
|
+
[PgOid.TIMESTAMPTZ_ARRAY]: (v) => normalizeArray(v, normalizeTimestamptz),
|
|
282
|
+
[PgOid.JSON_ARRAY]: (v) => normalizeArray(v, normalizeJson),
|
|
283
|
+
[PgOid.JSONB_ARRAY]: (v) => normalizeArray(v, normalizeJson),
|
|
284
|
+
[PgOid.BYTEA_ARRAY]: (v) => normalizeArray(v, normalizeBytes),
|
|
285
|
+
[PgOid.BIT_ARRAY]: (v) => normalizeArray(v, String),
|
|
286
|
+
[PgOid.VARBIT_ARRAY]: (v) => normalizeArray(v, String),
|
|
287
|
+
[PgOid.XML_ARRAY]: (v) => normalizeArray(v, String),
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// --- Type inference (fallback when .columns metadata is unavailable) ---
|
|
291
|
+
|
|
292
|
+
function inferArrayOid(arr: unknown[]): number {
|
|
293
|
+
if (arr.length === 0) return PgOid.TEXT_ARRAY;
|
|
294
|
+
const first = arr.find((v) => v !== null && v !== undefined);
|
|
295
|
+
if (first === undefined) return PgOid.TEXT_ARRAY;
|
|
296
|
+
if (typeof first === "number") {
|
|
297
|
+
return Number.isInteger(first) ? PgOid.INT8_ARRAY : PgOid.FLOAT8_ARRAY;
|
|
298
|
+
}
|
|
299
|
+
if (typeof first === "boolean") return PgOid.BOOL_ARRAY;
|
|
300
|
+
if (first instanceof Date) return PgOid.TIMESTAMPTZ_ARRAY;
|
|
301
|
+
if (typeof first === "object") return PgOid.JSONB_ARRAY;
|
|
302
|
+
return PgOid.TEXT_ARRAY;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Infer a PostgreSQL OID from a JavaScript value.
|
|
307
|
+
* Used as a fallback when Bun.sql doesn't expose column metadata.
|
|
308
|
+
* The inferred OID is then mapped to ColumnType via fieldToColumnType().
|
|
309
|
+
*/
|
|
310
|
+
export function inferOidFromValue(value: unknown): number {
|
|
311
|
+
if (value === null || value === undefined) return PgOid.TEXT;
|
|
312
|
+
if (typeof value === "boolean") return PgOid.BOOL;
|
|
313
|
+
if (typeof value === "bigint") return PgOid.INT8;
|
|
314
|
+
if (typeof value === "number") {
|
|
315
|
+
return Number.isInteger(value) ? PgOid.INT8 : PgOid.FLOAT8;
|
|
316
|
+
}
|
|
317
|
+
if (value instanceof Date) return PgOid.TIMESTAMPTZ;
|
|
318
|
+
if (value instanceof Uint8Array || value instanceof Buffer) return PgOid.BYTEA;
|
|
319
|
+
if (Array.isArray(value)) return inferArrayOid(value);
|
|
320
|
+
if (typeof value === "object") return PgOid.JSONB;
|
|
321
|
+
return PgOid.TEXT;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Find the first non-null value for a given column across all rows.
|
|
326
|
+
*/
|
|
327
|
+
export function findFirstNonNullInColumn(rows: unknown[][], colIndex: number): unknown {
|
|
328
|
+
for (const row of rows) {
|
|
329
|
+
const val = row[colIndex];
|
|
330
|
+
if (val !== null && val !== undefined) return val;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Array literal formatting (Bun.sql doesn't handle JS arrays in unsafe()) ---
|
|
336
|
+
|
|
337
|
+
function escapePgArrayElement(value: unknown): string {
|
|
338
|
+
if (value === null || value === undefined) return "NULL";
|
|
339
|
+
if (typeof value === "boolean") return value ? "t" : "f";
|
|
340
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
341
|
+
// Strings: escape backslashes and double-quotes, then wrap in quotes
|
|
342
|
+
const s = String(value);
|
|
343
|
+
return `"${s.replace(RE_PG_ESCAPE_BACKSLASH, "\\\\").replace(RE_PG_ESCAPE_QUOTE, '\\"')}"`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Convert a JS array to a PostgreSQL array literal string.
|
|
348
|
+
* Bun.sql's `unsafe()` does not serialize JS arrays as PostgreSQL arrays,
|
|
349
|
+
* so we must pass them in `{elem1,elem2,...}` format.
|
|
350
|
+
*/
|
|
351
|
+
export function toPgArrayLiteral(arr: unknown[]): string {
|
|
352
|
+
return `{${arr.map(escapePgArrayElement).join(",")}}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Argument mapping (input parameters) ---
|
|
356
|
+
|
|
357
|
+
export function mapArg(arg: unknown, argType: ArgType): unknown {
|
|
358
|
+
if (arg === null || arg === undefined) return null;
|
|
359
|
+
|
|
360
|
+
if (Array.isArray(arg) && argType.arity === "list") {
|
|
361
|
+
const mapped = arg.map((v) => mapArg(v, { ...argType, arity: "scalar" }));
|
|
362
|
+
return toPgArrayLiteral(mapped);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const value = typeof arg === "string" && argType.scalarType === "datetime" ? new Date(arg) : arg;
|
|
366
|
+
|
|
367
|
+
if (value instanceof Date) {
|
|
368
|
+
switch (argType.dbType) {
|
|
369
|
+
case "TIME":
|
|
370
|
+
case "TIMETZ":
|
|
371
|
+
return formatTime(value);
|
|
372
|
+
case "DATE":
|
|
373
|
+
return formatDate(value);
|
|
374
|
+
default:
|
|
375
|
+
return formatDateTime(value);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (typeof value === "string" && argType.scalarType === "bytes") {
|
|
380
|
+
return Buffer.from(value, "base64");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (ArrayBuffer.isView(value)) {
|
|
384
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return value;
|
|
388
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Error as AdapterError } from "@prisma/driver-adapter-utils";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
|
|
4
|
+
const RE_CONSTRAINT_KEY = /Key \((.+?)\)=/;
|
|
5
|
+
|
|
6
|
+
function extractConstraintFields(detail?: string): { fields: string[] } | undefined {
|
|
7
|
+
if (!detail) return;
|
|
8
|
+
// PostgreSQL detail format: "Key (field1, field2)=(value1, value2) already exists."
|
|
9
|
+
const match = detail.match(RE_CONSTRAINT_KEY);
|
|
10
|
+
if (match?.[1]) {
|
|
11
|
+
return { fields: match[1].split(",").map((s) => s.trim()) };
|
|
12
|
+
}
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the PostgreSQL error code from a Bun SQL.PostgresError.
|
|
18
|
+
* Bun.sql stores the PG error code in `errno` (e.g., "42P01"),
|
|
19
|
+
* while `code` contains Bun's internal error code (e.g., "ERR_POSTGRES_SERVER_ERROR").
|
|
20
|
+
*/
|
|
21
|
+
function getPgErrorCode(error: SQL.PostgresError): string {
|
|
22
|
+
return error.errno ?? error.code;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mapPostgresError(error: SQL.PostgresError): AdapterError {
|
|
26
|
+
const pgCode = getPgErrorCode(error);
|
|
27
|
+
const base = {
|
|
28
|
+
originalCode: pgCode,
|
|
29
|
+
originalMessage: error.message,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
switch (pgCode) {
|
|
33
|
+
// Value too long for type
|
|
34
|
+
case "22001":
|
|
35
|
+
return { ...base, column: error.column, kind: "LengthMismatch" };
|
|
36
|
+
|
|
37
|
+
// Numeric value out of range
|
|
38
|
+
case "22003":
|
|
39
|
+
return { ...base, cause: error.message, kind: "ValueOutOfRange" };
|
|
40
|
+
|
|
41
|
+
// Invalid text representation / invalid input syntax
|
|
42
|
+
case "22P02":
|
|
43
|
+
return { ...base, kind: "InvalidInputValue", message: error.message };
|
|
44
|
+
|
|
45
|
+
// Unique violation
|
|
46
|
+
case "23505": {
|
|
47
|
+
const constraint =
|
|
48
|
+
extractConstraintFields(error.detail) ?? (error.constraint ? { index: error.constraint } : undefined);
|
|
49
|
+
return { ...base, constraint, kind: "UniqueConstraintViolation" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Not null violation
|
|
53
|
+
case "23502": {
|
|
54
|
+
const constraint = error.column ? { fields: [error.column] } : undefined;
|
|
55
|
+
return { ...base, constraint, kind: "NullConstraintViolation" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Foreign key violation
|
|
59
|
+
case "23503": {
|
|
60
|
+
const constraint =
|
|
61
|
+
extractConstraintFields(error.detail) ?? (error.constraint ? { index: error.constraint } : undefined);
|
|
62
|
+
return { ...base, constraint, kind: "ForeignKeyConstraintViolation" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Database does not exist
|
|
66
|
+
case "3D000":
|
|
67
|
+
return { ...base, db: error.message, kind: "DatabaseDoesNotExist" };
|
|
68
|
+
|
|
69
|
+
// Database already exists
|
|
70
|
+
case "42P04":
|
|
71
|
+
return { ...base, db: error.message, kind: "DatabaseAlreadyExists" };
|
|
72
|
+
|
|
73
|
+
// Insufficient privilege / authorization
|
|
74
|
+
case "28000":
|
|
75
|
+
return { ...base, kind: "DatabaseAccessDenied" };
|
|
76
|
+
|
|
77
|
+
// Password authentication failed
|
|
78
|
+
case "28P01":
|
|
79
|
+
return { ...base, kind: "AuthenticationFailed" };
|
|
80
|
+
|
|
81
|
+
// Serialization failure (transaction conflict)
|
|
82
|
+
case "40001":
|
|
83
|
+
return { ...base, kind: "TransactionWriteConflict" };
|
|
84
|
+
|
|
85
|
+
// Undefined table
|
|
86
|
+
case "42P01":
|
|
87
|
+
return { ...base, kind: "TableDoesNotExist", table: error.table };
|
|
88
|
+
|
|
89
|
+
// Undefined column
|
|
90
|
+
case "42703":
|
|
91
|
+
return { ...base, column: error.column, kind: "ColumnNotFound" };
|
|
92
|
+
|
|
93
|
+
// Too many connections
|
|
94
|
+
case "53300":
|
|
95
|
+
return { ...base, cause: error.message, kind: "TooManyConnections" };
|
|
96
|
+
|
|
97
|
+
// Default: pass through as raw PostgreSQL error
|
|
98
|
+
default:
|
|
99
|
+
return {
|
|
100
|
+
...base,
|
|
101
|
+
code: pgCode,
|
|
102
|
+
column: error.column,
|
|
103
|
+
detail: error.detail,
|
|
104
|
+
hint: error.hint,
|
|
105
|
+
kind: "postgres",
|
|
106
|
+
message: error.message,
|
|
107
|
+
severity: error.severity ?? "ERROR",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function mapConnectionError(error: Error & { code?: string }): AdapterError {
|
|
113
|
+
const code = error.code ?? "";
|
|
114
|
+
const base = { originalCode: code, originalMessage: error.message };
|
|
115
|
+
|
|
116
|
+
if (code.includes("CONNECTION_CLOSED") || code === "ECONNRESET") {
|
|
117
|
+
return { ...base, kind: "ConnectionClosed" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
code.includes("CONNECTION_TIMEOUT") ||
|
|
122
|
+
code === "ETIMEDOUT" ||
|
|
123
|
+
code.includes("IDLE_TIMEOUT") ||
|
|
124
|
+
code.includes("LIFETIME_TIMEOUT")
|
|
125
|
+
) {
|
|
126
|
+
return { ...base, kind: "SocketTimeout" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (code.includes("TLS") || code.includes("SSL")) {
|
|
130
|
+
return { ...base, kind: "TlsConnectionError", reason: error.message };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (code.includes("AUTHENTICATION_FAILED")) {
|
|
134
|
+
return { ...base, kind: "AuthenticationFailed" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (code === "ENOTFOUND" || code === "ECONNREFUSED") {
|
|
138
|
+
return { ...base, kind: "DatabaseNotReachable" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
code,
|
|
144
|
+
column: undefined,
|
|
145
|
+
detail: undefined,
|
|
146
|
+
hint: undefined,
|
|
147
|
+
kind: "postgres",
|
|
148
|
+
message: error.message,
|
|
149
|
+
severity: "ERROR",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function convertDriverError(error: unknown): AdapterError {
|
|
154
|
+
if (error instanceof SQL.PostgresError) {
|
|
155
|
+
return mapPostgresError(error);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (error instanceof SQL.SQLError) {
|
|
159
|
+
return mapConnectionError(error as Error & { code?: string });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (error instanceof Error) {
|
|
163
|
+
return mapConnectionError(error as Error & { code?: string });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
code: "UNKNOWN",
|
|
168
|
+
column: undefined,
|
|
169
|
+
detail: undefined,
|
|
170
|
+
hint: undefined,
|
|
171
|
+
kind: "postgres",
|
|
172
|
+
message: String(error),
|
|
173
|
+
severity: "ERROR",
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { SqlDriverAdapter, SqlMigrationAwareDriverAdapterFactory } from "@prisma/driver-adapter-utils";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { PrismaBunAdapter } from "./adapter.ts";
|
|
4
|
+
import type { BunSqlConfig, PrismaBunOptions } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
const ADAPTER_NAME = "@onreza/prisma-adapter-bun";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect a Bun SQL client instance via duck-typing.
|
|
10
|
+
* `instanceof SQL` doesn't work because SQL.prototype is undefined in Bun.
|
|
11
|
+
*/
|
|
12
|
+
function isSqlClient(value: unknown): value is SQL {
|
|
13
|
+
const v = value as Record<string, unknown>;
|
|
14
|
+
return (
|
|
15
|
+
typeof value === "function" &&
|
|
16
|
+
typeof v.unsafe === "function" &&
|
|
17
|
+
typeof v.reserve === "function" &&
|
|
18
|
+
typeof v.close === "function"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class PrismaBunFactory implements SqlMigrationAwareDriverAdapterFactory {
|
|
23
|
+
readonly provider = "postgres" as const;
|
|
24
|
+
readonly adapterName = ADAPTER_NAME;
|
|
25
|
+
|
|
26
|
+
private readonly config: BunSqlConfig;
|
|
27
|
+
private readonly bunOptions?: PrismaBunOptions;
|
|
28
|
+
private externalClient: SQL | null = null;
|
|
29
|
+
|
|
30
|
+
constructor(config: BunSqlConfig, options?: PrismaBunOptions);
|
|
31
|
+
constructor(client: SQL, options?: PrismaBunOptions);
|
|
32
|
+
constructor(configOrClient: BunSqlConfig | SQL, options?: PrismaBunOptions) {
|
|
33
|
+
this.bunOptions = options;
|
|
34
|
+
if (isSqlClient(configOrClient)) {
|
|
35
|
+
this.externalClient = configOrClient;
|
|
36
|
+
this.config = {};
|
|
37
|
+
} else {
|
|
38
|
+
this.config = configOrClient;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
connect(): Promise<SqlDriverAdapter> {
|
|
43
|
+
if (this.externalClient) {
|
|
44
|
+
return Promise.resolve(new PrismaBunAdapter(this.externalClient, this.bunOptions, () => Promise.resolve()));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const client = new SQL(this.config as SQL.Options);
|
|
48
|
+
|
|
49
|
+
return Promise.resolve(
|
|
50
|
+
new PrismaBunAdapter(client, this.bunOptions, async () => {
|
|
51
|
+
await client.close();
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async connectToShadowDb(): Promise<SqlDriverAdapter> {
|
|
57
|
+
const mainAdapter = await this.connect();
|
|
58
|
+
const database = `prisma_migrate_shadow_db_${crypto.randomUUID()}`;
|
|
59
|
+
|
|
60
|
+
await mainAdapter.executeScript(`CREATE DATABASE "${database}"`);
|
|
61
|
+
|
|
62
|
+
let shadowClient: SQL;
|
|
63
|
+
try {
|
|
64
|
+
const shadowConfig = this.buildShadowConfig(database);
|
|
65
|
+
shadowClient = new SQL(shadowConfig as SQL.Options);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
try {
|
|
68
|
+
await mainAdapter.executeScript(`DROP DATABASE IF EXISTS "${database}"`);
|
|
69
|
+
} finally {
|
|
70
|
+
await mainAdapter.dispose();
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return new PrismaBunAdapter(shadowClient, this.bunOptions, async () => {
|
|
76
|
+
await shadowClient.close();
|
|
77
|
+
try {
|
|
78
|
+
await mainAdapter.executeScript(`DROP DATABASE IF EXISTS "${database}"`);
|
|
79
|
+
} finally {
|
|
80
|
+
await mainAdapter.dispose();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private buildShadowConfig(database: string): SQL.PostgresOrMySQLOptions {
|
|
86
|
+
if (typeof this.config === "string" || this.config instanceof URL) {
|
|
87
|
+
const url = new URL(this.config.toString());
|
|
88
|
+
url.pathname = `/${database}`;
|
|
89
|
+
return { url: url.toString() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof this.config === "object" && this.config !== null) {
|
|
93
|
+
const opts = this.config as SQL.PostgresOrMySQLOptions;
|
|
94
|
+
if (opts.url) {
|
|
95
|
+
const url = new URL(opts.url.toString());
|
|
96
|
+
url.pathname = `/${database}`;
|
|
97
|
+
return { ...opts, url: url.toString() };
|
|
98
|
+
}
|
|
99
|
+
return { ...opts, database };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { database };
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED