@nextlyhq/adapter-sqlite 0.0.1
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 +22 -0
- package/README.md +102 -0
- package/dist/index.cjs +572 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +288 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.mjs +567 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NextlyHQ <info@nextlyhq.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
'Software'), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
20
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
21
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
22
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @nextlyhq/adapter-sqlite
|
|
2
|
+
|
|
3
|
+
SQLite database adapter for Nextly. Built on `better-sqlite3` for synchronous file-based persistence.
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@nextlyhq/adapter-sqlite"><img alt="npm" src="https://img.shields.io/npm/v/@nextlyhq/adapter-sqlite?style=flat-square&label=npm&color=cb3837" /></a>
|
|
7
|
+
<a href="https://github.com/nextlyhq/nextly/blob/main/LICENSE.md"><img alt="License" src="https://img.shields.io/github/license/nextlyhq/nextly?style=flat-square&color=blue" /></a>
|
|
8
|
+
<a href="https://nextlyhq.com/docs"><img alt="Status" src="https://img.shields.io/badge/status-alpha-orange?style=flat-square" /></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
> [!IMPORTANT]
|
|
12
|
+
> Nextly is in alpha. APIs may change before 1.0. Pin exact versions in production.
|
|
13
|
+
|
|
14
|
+
> [!WARNING]
|
|
15
|
+
> **SQLite is for local demos and quick experiments only.** It is single-writer, file-based, has no SSL, and emulates several features (see [Dialect notes](#dialect-notes) below). The adapter exists so you can try Nextly without provisioning a database server. For any real project, even local development, use [`@nextlyhq/adapter-postgres`](../adapter-postgres). The fastest way to run Postgres locally is `docker compose up -d postgres`.
|
|
16
|
+
|
|
17
|
+
## What it is
|
|
18
|
+
|
|
19
|
+
The SQLite adapter for Nextly. Use this for one-off local demos or quick experiments where setting up a database server would be friction.
|
|
20
|
+
|
|
21
|
+
For any project beyond a quick demo, use [`@nextlyhq/adapter-postgres`](../adapter-postgres). PostgreSQL has the full feature set Nextly is designed around; SQLite emulates a subset.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add @nextlyhq/adapter-sqlite better-sqlite3
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick usage
|
|
30
|
+
|
|
31
|
+
Nextly selects the adapter from your `.env` file. Install the package and set:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
DB_DIALECT=sqlite
|
|
35
|
+
DATABASE_URL=file:./data/nextly.db
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The path can be relative (resolved against the project root) or absolute. The directory must exist.
|
|
39
|
+
|
|
40
|
+
## Required environment variables
|
|
41
|
+
|
|
42
|
+
| Variable | Required? | Default | Notes |
|
|
43
|
+
| -------------- | ----------- | ------------------------ | ---------------------------------------------- |
|
|
44
|
+
| `DATABASE_URL` | yes | (none) | `file:./path/to/db.sqlite` or absolute path. |
|
|
45
|
+
| `DB_DIALECT` | recommended | (auto-detected from URL) | Set explicitly to silence the warning at boot. |
|
|
46
|
+
|
|
47
|
+
## Programmatic usage (advanced)
|
|
48
|
+
|
|
49
|
+
For test harnesses or scripts:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createSqliteAdapter } from "@nextlyhq/adapter-sqlite";
|
|
53
|
+
|
|
54
|
+
const adapter = createSqliteAdapter({
|
|
55
|
+
url: process.env.DATABASE_URL!,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await adapter.connect();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Most users do not need this.
|
|
62
|
+
|
|
63
|
+
## Supported SQLite versions
|
|
64
|
+
|
|
65
|
+
- SQLite 3.38 or newer required (bundled with `better-sqlite3`)
|
|
66
|
+
|
|
67
|
+
## Dialect notes
|
|
68
|
+
|
|
69
|
+
- **Single writer.** SQLite serializes writes; concurrent transactions queue. Fine for one user, painful for production traffic.
|
|
70
|
+
- **No SSL or TLS.** Not applicable to a local file.
|
|
71
|
+
- **Limited types.** No native arrays, no JSONB; JSON is stored as TEXT. ILIKE is emulated as `LOWER(...) LIKE LOWER(...)`.
|
|
72
|
+
- **Savepoints.** Supported.
|
|
73
|
+
- **`RETURNING` clause.** Supported (SQLite 3.35+).
|
|
74
|
+
|
|
75
|
+
## Main exports
|
|
76
|
+
|
|
77
|
+
- `SqliteAdapter`: the adapter class
|
|
78
|
+
- `createSqliteAdapter`: factory for programmatic use
|
|
79
|
+
- `isSqliteAdapter`: type guard
|
|
80
|
+
- Type exports: `SqliteAdapterConfig`
|
|
81
|
+
|
|
82
|
+
## Compatibility
|
|
83
|
+
|
|
84
|
+
| Tool | Version |
|
|
85
|
+
| ---------------- | ------- |
|
|
86
|
+
| Node.js | 20+ |
|
|
87
|
+
| `better-sqlite3` | 11+ |
|
|
88
|
+
| `nextly` | 0.0.x |
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
- [**SQLite adapter docs**](https://nextlyhq.com/docs/database/sqlite)
|
|
93
|
+
- [**Database support and version policy**](https://nextlyhq.com/docs/database/support)
|
|
94
|
+
|
|
95
|
+
## Related packages
|
|
96
|
+
|
|
97
|
+
- [`@nextlyhq/adapter-postgres`](../adapter-postgres): recommended for production
|
|
98
|
+
- [`@nextlyhq/adapter-mysql`](../adapter-mysql)
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
[MIT](../../LICENSE.md)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var adapterDrizzle = require('@nextlyhq/adapter-drizzle');
|
|
4
|
+
var types = require('@nextlyhq/adapter-drizzle/types');
|
|
5
|
+
var versionCheck = require('@nextlyhq/adapter-drizzle/version-check');
|
|
6
|
+
var betterSqlite3 = require('drizzle-orm/better-sqlite3');
|
|
7
|
+
|
|
8
|
+
// src/index.ts
|
|
9
|
+
var VERSION = "0.1.0";
|
|
10
|
+
var SQLITE_ERROR_CODES = {
|
|
11
|
+
// Constraint violations
|
|
12
|
+
SQLITE_CONSTRAINT: "constraint",
|
|
13
|
+
SQLITE_CONSTRAINT_UNIQUE: "unique_violation",
|
|
14
|
+
SQLITE_CONSTRAINT_PRIMARYKEY: "unique_violation",
|
|
15
|
+
SQLITE_CONSTRAINT_FOREIGNKEY: "foreign_key_violation",
|
|
16
|
+
SQLITE_CONSTRAINT_NOTNULL: "not_null_violation",
|
|
17
|
+
SQLITE_CONSTRAINT_CHECK: "check_violation",
|
|
18
|
+
// Busy/locked errors
|
|
19
|
+
SQLITE_BUSY: "timeout",
|
|
20
|
+
SQLITE_LOCKED: "timeout",
|
|
21
|
+
// Connection errors
|
|
22
|
+
SQLITE_CANTOPEN: "connection",
|
|
23
|
+
SQLITE_NOTADB: "connection",
|
|
24
|
+
SQLITE_CORRUPT: "connection",
|
|
25
|
+
// Query errors
|
|
26
|
+
SQLITE_ERROR: "query",
|
|
27
|
+
SQLITE_MISUSE: "query",
|
|
28
|
+
SQLITE_RANGE: "query"
|
|
29
|
+
};
|
|
30
|
+
var DEFAULT_CONFIG = {
|
|
31
|
+
busyTimeout: 5e3,
|
|
32
|
+
wal: true,
|
|
33
|
+
foreignKeys: true
|
|
34
|
+
};
|
|
35
|
+
function sanitizeSqliteValue(v) {
|
|
36
|
+
if (v === void 0) return null;
|
|
37
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
38
|
+
if (v instanceof Date) return v.toISOString();
|
|
39
|
+
if (v !== null && typeof v === "object") return JSON.stringify(v);
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
var SqliteAdapter = class extends adapterDrizzle.DrizzleAdapter {
|
|
43
|
+
/**
|
|
44
|
+
* The database dialect - always 'sqlite' for this adapter.
|
|
45
|
+
*/
|
|
46
|
+
dialect = "sqlite";
|
|
47
|
+
/**
|
|
48
|
+
* Adapter configuration.
|
|
49
|
+
*/
|
|
50
|
+
config;
|
|
51
|
+
/**
|
|
52
|
+
* better-sqlite3 Database instance.
|
|
53
|
+
*/
|
|
54
|
+
db = null;
|
|
55
|
+
/**
|
|
56
|
+
* Connection state flag.
|
|
57
|
+
*/
|
|
58
|
+
connected = false;
|
|
59
|
+
/**
|
|
60
|
+
* Serialization queue for concurrent `transaction()` calls.
|
|
61
|
+
*
|
|
62
|
+
* better-sqlite3 is synchronous, single-connection, and rejects nested
|
|
63
|
+
* `BEGIN` with "cannot start a transaction within a transaction".
|
|
64
|
+
* When two `await adapter.transaction(...)` calls overlap (e.g. a bulk
|
|
65
|
+
* mutation calling `Promise.allSettled` with N per-row updates), the
|
|
66
|
+
* second one tries to BEGIN while the first is still open and the
|
|
67
|
+
* driver throws.
|
|
68
|
+
*
|
|
69
|
+
* Postgres/MySQL avoid this by allocating a fresh client/connection
|
|
70
|
+
* per transaction from a pool — there is no equivalent for
|
|
71
|
+
* better-sqlite3, so we serialize at the JS layer instead. Every
|
|
72
|
+
* `transaction()` invocation chains onto this promise; only one
|
|
73
|
+
* BEGIN→COMMIT/ROLLBACK section runs at any moment, preserving
|
|
74
|
+
* write-isolation semantics that callers expect from a transactional
|
|
75
|
+
* adapter.
|
|
76
|
+
*
|
|
77
|
+
* Performance impact is the natural floor: SQLite already serializes
|
|
78
|
+
* writers at the file-lock level, so the queue removes failed-call
|
|
79
|
+
* surface area without changing the achievable concurrency.
|
|
80
|
+
*/
|
|
81
|
+
transactionQueue = Promise.resolve();
|
|
82
|
+
/**
|
|
83
|
+
* Creates a new SQLite adapter instance.
|
|
84
|
+
*
|
|
85
|
+
* @param config - Adapter configuration
|
|
86
|
+
*/
|
|
87
|
+
constructor(config) {
|
|
88
|
+
super();
|
|
89
|
+
this.config = config;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Connect to the SQLite database.
|
|
93
|
+
*
|
|
94
|
+
* @remarks
|
|
95
|
+
* This method initializes the database connection. For SQLite, this
|
|
96
|
+
* opens the database file or creates an in-memory database.
|
|
97
|
+
* Also configures WAL mode and foreign keys based on config.
|
|
98
|
+
*
|
|
99
|
+
* @throws {DatabaseError} If connection fails
|
|
100
|
+
*/
|
|
101
|
+
async connect() {
|
|
102
|
+
if (this.connected && this.db) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const BetterSqlite3 = await import('better-sqlite3');
|
|
107
|
+
const Database = BetterSqlite3.default;
|
|
108
|
+
let dbPath;
|
|
109
|
+
if (this.config.memory) {
|
|
110
|
+
dbPath = ":memory:";
|
|
111
|
+
} else if (this.config.url) {
|
|
112
|
+
dbPath = this.config.url.replace(/^file:/, "");
|
|
113
|
+
} else {
|
|
114
|
+
dbPath = ":memory:";
|
|
115
|
+
}
|
|
116
|
+
if (dbPath !== ":memory:" && !this.config.readonly) {
|
|
117
|
+
const fs = await import('fs');
|
|
118
|
+
const path = await import('path');
|
|
119
|
+
const dir = path.dirname(path.resolve(dbPath));
|
|
120
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
this.db = new Database(dbPath, {
|
|
123
|
+
readonly: this.config.readonly ?? false,
|
|
124
|
+
timeout: this.config.busyTimeout ?? DEFAULT_CONFIG.busyTimeout
|
|
125
|
+
});
|
|
126
|
+
if ((this.config.wal ?? DEFAULT_CONFIG.wal) && dbPath !== ":memory:" && !this.config.readonly) {
|
|
127
|
+
this.db.pragma("journal_mode = WAL");
|
|
128
|
+
}
|
|
129
|
+
if (this.config.foreignKeys ?? DEFAULT_CONFIG.foreignKeys) {
|
|
130
|
+
this.db.pragma("foreign_keys = ON");
|
|
131
|
+
}
|
|
132
|
+
await versionCheck.checkDialectVersion(this.db, "sqlite");
|
|
133
|
+
this.connected = true;
|
|
134
|
+
if (this.config.logger?.info) {
|
|
135
|
+
this.config.logger.info("SQLite connection established", {
|
|
136
|
+
url: dbPath === ":memory:" ? "in-memory" : dbPath,
|
|
137
|
+
wal: this.config.wal ?? DEFAULT_CONFIG.wal,
|
|
138
|
+
foreignKeys: this.config.foreignKeys ?? DEFAULT_CONFIG.foreignKeys
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (this.db) {
|
|
143
|
+
try {
|
|
144
|
+
this.db.close();
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
this.db = null;
|
|
148
|
+
}
|
|
149
|
+
throw this.classifyError(error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Disconnect from the SQLite database.
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* This method closes the database connection and releases resources.
|
|
157
|
+
*/
|
|
158
|
+
// better-sqlite3 is synchronous; method is async to satisfy the DatabaseAdapter contract.
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
160
|
+
async disconnect() {
|
|
161
|
+
if (!this.db) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
this.db.close();
|
|
166
|
+
if (this.config.logger?.info) {
|
|
167
|
+
this.config.logger.info("SQLite connection closed");
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
this.db = null;
|
|
171
|
+
this.connected = false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Check if connected to the database.
|
|
176
|
+
*/
|
|
177
|
+
isConnected() {
|
|
178
|
+
return this.connected && this.db !== null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Get connection pool statistics.
|
|
182
|
+
*
|
|
183
|
+
* @remarks
|
|
184
|
+
* SQLite doesn't use connection pooling, so this returns null.
|
|
185
|
+
* The database is single-file with a single connection.
|
|
186
|
+
*/
|
|
187
|
+
getPoolStats() {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Execute a raw SQL query.
|
|
192
|
+
*
|
|
193
|
+
* @param sql - SQL query string with $1, $2 placeholders (converted to ? for SQLite)
|
|
194
|
+
* @param params - Query parameters
|
|
195
|
+
* @returns Query results
|
|
196
|
+
*
|
|
197
|
+
* @throws {DatabaseError} If query execution fails
|
|
198
|
+
*/
|
|
199
|
+
// better-sqlite3 is synchronous; method is async to satisfy the DatabaseAdapter contract.
|
|
200
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
201
|
+
async executeQuery(sql, params = []) {
|
|
202
|
+
const db = this.ensureDb();
|
|
203
|
+
const startTime = Date.now();
|
|
204
|
+
try {
|
|
205
|
+
const convertedSql = this.convertPlaceholders(sql);
|
|
206
|
+
const trimmedSql = convertedSql.trim().toUpperCase();
|
|
207
|
+
const isPragmaQuery = trimmedSql.startsWith("PRAGMA") && !trimmedSql.includes("=");
|
|
208
|
+
const isSelect = trimmedSql.startsWith("SELECT") || isPragmaQuery || trimmedSql.startsWith("WITH");
|
|
209
|
+
const hasReturning = trimmedSql.includes("RETURNING");
|
|
210
|
+
let result;
|
|
211
|
+
const sanitizedParams = params.map(sanitizeSqliteValue);
|
|
212
|
+
if (isSelect || hasReturning) {
|
|
213
|
+
const stmt = db.prepare(convertedSql);
|
|
214
|
+
result = stmt.all(...sanitizedParams);
|
|
215
|
+
} else {
|
|
216
|
+
const stmt = db.prepare(convertedSql);
|
|
217
|
+
const runResult = stmt.run(...sanitizedParams);
|
|
218
|
+
result = [
|
|
219
|
+
{
|
|
220
|
+
changes: runResult.changes,
|
|
221
|
+
lastInsertRowid: runResult.lastInsertRowid
|
|
222
|
+
}
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
if (this.config.logger?.query) {
|
|
226
|
+
const durationMs = Date.now() - startTime;
|
|
227
|
+
this.config.logger.query(convertedSql, params, durationMs);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
throw this.classifyError(error, sql);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Execute work within a transaction.
|
|
236
|
+
*
|
|
237
|
+
* @param work - Function containing transactional operations
|
|
238
|
+
* @param options - Transaction options (isolation level not fully supported in SQLite)
|
|
239
|
+
* @returns Result of the work function
|
|
240
|
+
*
|
|
241
|
+
* @remarks
|
|
242
|
+
* Uses better-sqlite3's native transaction handling which:
|
|
243
|
+
* - Automatically commits on success
|
|
244
|
+
* - Automatically rolls back on error
|
|
245
|
+
* - Converts nested transactions to savepoints
|
|
246
|
+
*
|
|
247
|
+
* Note: SQLite uses DEFERRED, IMMEDIATE, or EXCLUSIVE transaction modes
|
|
248
|
+
* rather than isolation levels. This adapter uses IMMEDIATE by default.
|
|
249
|
+
*/
|
|
250
|
+
async transaction(work, _options) {
|
|
251
|
+
const run = async () => this.runTransaction(work);
|
|
252
|
+
const next = this.transactionQueue.then(run, run);
|
|
253
|
+
this.transactionQueue = next.catch(() => void 0);
|
|
254
|
+
return next;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Inner transaction body. Kept private so callers always go through
|
|
258
|
+
* the serialized `transaction()` queue above and the BEGIN/COMMIT
|
|
259
|
+
* pair is never invoked outside of it.
|
|
260
|
+
*/
|
|
261
|
+
async runTransaction(work) {
|
|
262
|
+
const db = this.ensureDb();
|
|
263
|
+
const startTime = Date.now();
|
|
264
|
+
try {
|
|
265
|
+
const ctx = this.createTransactionContext(db);
|
|
266
|
+
db.exec("BEGIN IMMEDIATE");
|
|
267
|
+
try {
|
|
268
|
+
const result = await work(ctx);
|
|
269
|
+
db.exec("COMMIT");
|
|
270
|
+
if (this.config.logger?.debug) {
|
|
271
|
+
const durationMs = Date.now() - startTime;
|
|
272
|
+
this.config.logger.debug("Transaction committed", {
|
|
273
|
+
durationMs
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
try {
|
|
279
|
+
db.exec("ROLLBACK");
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
throw this.classifyError(error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Get SQLite database capabilities.
|
|
290
|
+
*
|
|
291
|
+
* @remarks
|
|
292
|
+
* SQLite capabilities:
|
|
293
|
+
* - JSON support (not JSONB)
|
|
294
|
+
* - No arrays
|
|
295
|
+
* - No native ILIKE
|
|
296
|
+
* - RETURNING clause (3.35+)
|
|
297
|
+
* - Savepoints supported
|
|
298
|
+
* - ON CONFLICT supported
|
|
299
|
+
*/
|
|
300
|
+
getCapabilities() {
|
|
301
|
+
return {
|
|
302
|
+
dialect: "sqlite",
|
|
303
|
+
supportsJsonb: false,
|
|
304
|
+
// SQLite uses JSON, not JSONB
|
|
305
|
+
supportsJson: true,
|
|
306
|
+
supportsArrays: false,
|
|
307
|
+
// SQLite doesn't support array types
|
|
308
|
+
supportsGeneratedColumns: true,
|
|
309
|
+
// SQLite 3.31+
|
|
310
|
+
supportsFts: true,
|
|
311
|
+
// SQLite FTS5
|
|
312
|
+
supportsIlike: false,
|
|
313
|
+
// No native ILIKE, use LOWER() LIKE
|
|
314
|
+
supportsReturning: true,
|
|
315
|
+
// SQLite 3.35+
|
|
316
|
+
supportsSavepoints: true,
|
|
317
|
+
// SQLite supports savepoints
|
|
318
|
+
supportsOnConflict: true,
|
|
319
|
+
// ON CONFLICT clause
|
|
320
|
+
maxParamsPerQuery: 999,
|
|
321
|
+
// SQLite SQLITE_MAX_VARIABLE_NUMBER default
|
|
322
|
+
maxIdentifierLength: 128
|
|
323
|
+
// SQLite doesn't have a strict limit
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Override insertMany for bulk insert optimization.
|
|
328
|
+
*
|
|
329
|
+
* @remarks
|
|
330
|
+
* Uses a single multi-row INSERT statement for better performance.
|
|
331
|
+
*/
|
|
332
|
+
async insertMany(table, data, options) {
|
|
333
|
+
if (data.length === 0) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const db = this.ensureDb();
|
|
337
|
+
if (data.length === 1) {
|
|
338
|
+
const result = await this.insert(table, data[0], options);
|
|
339
|
+
return [result];
|
|
340
|
+
}
|
|
341
|
+
const columns = Object.keys(data[0]);
|
|
342
|
+
const params = [];
|
|
343
|
+
const valuesClauses = [];
|
|
344
|
+
for (const record of data) {
|
|
345
|
+
const placeholders = [];
|
|
346
|
+
for (const col of columns) {
|
|
347
|
+
params.push(sanitizeSqliteValue(record[col]));
|
|
348
|
+
placeholders.push("?");
|
|
349
|
+
}
|
|
350
|
+
valuesClauses.push(`(${placeholders.join(", ")})`);
|
|
351
|
+
}
|
|
352
|
+
const columnList = columns.map((col) => this.escapeIdentifier(col)).join(", ");
|
|
353
|
+
let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columnList}) VALUES ${valuesClauses.join(", ")}`;
|
|
354
|
+
if (options?.returning) {
|
|
355
|
+
const returning = options.returning === "*" ? "*" : options.returning.map((col) => this.escapeIdentifier(col)).join(", ");
|
|
356
|
+
sql += ` RETURNING ${returning}`;
|
|
357
|
+
} else {
|
|
358
|
+
sql += " RETURNING *";
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const stmt = db.prepare(sql);
|
|
362
|
+
const rows = stmt.all(...params);
|
|
363
|
+
return rows;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
throw this.handleQueryError(error, "insertMany", table);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// ============================================================
|
|
369
|
+
// Protected Helper Methods
|
|
370
|
+
// ============================================================
|
|
371
|
+
/**
|
|
372
|
+
* Ensures database is connected and returns it.
|
|
373
|
+
*
|
|
374
|
+
* @throws {DatabaseError} If not connected
|
|
375
|
+
*/
|
|
376
|
+
ensureDb() {
|
|
377
|
+
if (!this.db) {
|
|
378
|
+
throw types.createDatabaseError({
|
|
379
|
+
kind: "connection",
|
|
380
|
+
message: "SqliteAdapter is not connected. Call connect() first."
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return this.db;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Return the typed Drizzle instance for SQLite.
|
|
387
|
+
* Guarded for server-only usage and requires an active connection.
|
|
388
|
+
*
|
|
389
|
+
* @param schema - Optional schema for relational queries (db.query.*)
|
|
390
|
+
* @returns Drizzle ORM instance wrapping the better-sqlite3 connection
|
|
391
|
+
* @throws {Error} If called in browser or not connected
|
|
392
|
+
*/
|
|
393
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
394
|
+
getDrizzle(schema) {
|
|
395
|
+
if (typeof window !== "undefined") {
|
|
396
|
+
throw new Error("getDrizzle() is server-only");
|
|
397
|
+
}
|
|
398
|
+
const db = this.ensureDb();
|
|
399
|
+
return schema ? betterSqlite3.drizzle(db, { schema }) : betterSqlite3.drizzle(db);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Convert $1, $2 placeholders to ? for better-sqlite3.
|
|
403
|
+
*
|
|
404
|
+
* @param sql - SQL with PostgreSQL-style placeholders
|
|
405
|
+
* @returns SQL with ? placeholders
|
|
406
|
+
*/
|
|
407
|
+
convertPlaceholders(sql) {
|
|
408
|
+
return sql.replace(/\$\d+/g, "?");
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Creates a TransactionContext for the given database connection.
|
|
412
|
+
*/
|
|
413
|
+
createTransactionContext(db) {
|
|
414
|
+
return {
|
|
415
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
416
|
+
execute: async (sql, params = []) => {
|
|
417
|
+
const convertedSql = this.convertPlaceholders(sql);
|
|
418
|
+
const trimmedSql = convertedSql.trim().toUpperCase();
|
|
419
|
+
const isSelect = trimmedSql.startsWith("SELECT") || trimmedSql.includes("RETURNING");
|
|
420
|
+
const sanitizedParams = params.map(sanitizeSqliteValue);
|
|
421
|
+
if (isSelect) {
|
|
422
|
+
const stmt = db.prepare(convertedSql);
|
|
423
|
+
return stmt.all(...sanitizedParams);
|
|
424
|
+
} else {
|
|
425
|
+
const stmt = db.prepare(convertedSql);
|
|
426
|
+
const result = stmt.run(...sanitizedParams);
|
|
427
|
+
return [
|
|
428
|
+
{
|
|
429
|
+
changes: result.changes,
|
|
430
|
+
lastInsertRowid: result.lastInsertRowid
|
|
431
|
+
}
|
|
432
|
+
];
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
436
|
+
insert: async (table, data, options) => {
|
|
437
|
+
const columns = Object.keys(data);
|
|
438
|
+
const values = Object.values(data).map(sanitizeSqliteValue);
|
|
439
|
+
const placeholders = values.map(() => "?").join(", ");
|
|
440
|
+
let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES (${placeholders})`;
|
|
441
|
+
if (options?.returning) {
|
|
442
|
+
const returning = options.returning === "*" ? "*" : options.returning.map((col) => this.escapeIdentifier(col)).join(", ");
|
|
443
|
+
sql += ` RETURNING ${returning}`;
|
|
444
|
+
} else {
|
|
445
|
+
sql += " RETURNING *";
|
|
446
|
+
}
|
|
447
|
+
const stmt = db.prepare(sql);
|
|
448
|
+
const rows = stmt.all(...values);
|
|
449
|
+
return rows[0];
|
|
450
|
+
},
|
|
451
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
452
|
+
insertMany: async (table, data, options) => {
|
|
453
|
+
if (data.length === 0) return [];
|
|
454
|
+
const columns = Object.keys(data[0]);
|
|
455
|
+
const allValues = [];
|
|
456
|
+
const valuesClauses = [];
|
|
457
|
+
for (const record of data) {
|
|
458
|
+
const placeholders = [];
|
|
459
|
+
for (const col of columns) {
|
|
460
|
+
allValues.push(sanitizeSqliteValue(record[col]));
|
|
461
|
+
placeholders.push("?");
|
|
462
|
+
}
|
|
463
|
+
valuesClauses.push(`(${placeholders.join(", ")})`);
|
|
464
|
+
}
|
|
465
|
+
let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES ${valuesClauses.join(", ")}`;
|
|
466
|
+
if (options?.returning) {
|
|
467
|
+
const returning = options.returning === "*" ? "*" : options.returning.map((col) => this.escapeIdentifier(col)).join(", ");
|
|
468
|
+
sql += ` RETURNING ${returning}`;
|
|
469
|
+
} else {
|
|
470
|
+
sql += " RETURNING *";
|
|
471
|
+
}
|
|
472
|
+
const stmt = db.prepare(sql);
|
|
473
|
+
return stmt.all(...allValues);
|
|
474
|
+
},
|
|
475
|
+
// TransactionContext CRUD methods delegate to the adapter's CRUD
|
|
476
|
+
// which uses Drizzle query API via the TableResolver.
|
|
477
|
+
select: async (table, options) => {
|
|
478
|
+
return this.select(table, options);
|
|
479
|
+
},
|
|
480
|
+
selectOne: async (table, options) => {
|
|
481
|
+
return this.selectOne(table, options);
|
|
482
|
+
},
|
|
483
|
+
update: async (table, data, where, options) => {
|
|
484
|
+
return this.update(table, data, where, options);
|
|
485
|
+
},
|
|
486
|
+
delete: async (table, where, _options) => {
|
|
487
|
+
return this.delete(table, where);
|
|
488
|
+
},
|
|
489
|
+
upsert: async (table, data, options) => {
|
|
490
|
+
return this.upsert(table, data, options);
|
|
491
|
+
},
|
|
492
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
493
|
+
savepoint: async (name) => {
|
|
494
|
+
db.exec(`SAVEPOINT ${this.escapeIdentifier(name)}`);
|
|
495
|
+
},
|
|
496
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
497
|
+
rollbackToSavepoint: async (name) => {
|
|
498
|
+
db.exec(`ROLLBACK TO SAVEPOINT ${this.escapeIdentifier(name)}`);
|
|
499
|
+
},
|
|
500
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
501
|
+
releaseSavepoint: async (name) => {
|
|
502
|
+
db.exec(`RELEASE SAVEPOINT ${this.escapeIdentifier(name)}`);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Classifies a SQLite error into a DatabaseError.
|
|
508
|
+
*
|
|
509
|
+
* @param error - Original error from better-sqlite3
|
|
510
|
+
* @param sql - SQL statement that caused the error (optional)
|
|
511
|
+
* @returns DatabaseError with proper classification
|
|
512
|
+
*/
|
|
513
|
+
classifyError(error, sql) {
|
|
514
|
+
if (types.isDatabaseError(error)) return error;
|
|
515
|
+
const sqliteError = error;
|
|
516
|
+
let kind = "unknown";
|
|
517
|
+
if (sqliteError.code) {
|
|
518
|
+
kind = SQLITE_ERROR_CODES[sqliteError.code] || "unknown";
|
|
519
|
+
} else if (sqliteError.message) {
|
|
520
|
+
const msg = sqliteError.message.toUpperCase();
|
|
521
|
+
if (msg.includes("UNIQUE CONSTRAINT")) {
|
|
522
|
+
kind = "unique_violation";
|
|
523
|
+
} else if (msg.includes("FOREIGN KEY CONSTRAINT")) {
|
|
524
|
+
kind = "foreign_key_violation";
|
|
525
|
+
} else if (msg.includes("NOT NULL CONSTRAINT")) {
|
|
526
|
+
kind = "not_null_violation";
|
|
527
|
+
} else if (msg.includes("CHECK CONSTRAINT")) {
|
|
528
|
+
kind = "check_violation";
|
|
529
|
+
} else if (msg.includes("BUSY") || msg.includes("LOCKED")) {
|
|
530
|
+
kind = "timeout";
|
|
531
|
+
} else if (msg.includes("SQLITE_CANTOPEN") || msg.includes("UNABLE TO OPEN")) {
|
|
532
|
+
kind = "connection";
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
let message = sqliteError.message ?? String(error);
|
|
536
|
+
if (sql && kind === "query") {
|
|
537
|
+
message = `Query failed: ${message}`;
|
|
538
|
+
}
|
|
539
|
+
return types.createDatabaseError({
|
|
540
|
+
kind,
|
|
541
|
+
message,
|
|
542
|
+
code: sqliteError.code,
|
|
543
|
+
cause: error instanceof Error ? error : void 0
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Override handleQueryError to use SQLite-specific classification.
|
|
548
|
+
*/
|
|
549
|
+
handleQueryError(error, operation, table) {
|
|
550
|
+
const dbError = this.classifyError(error);
|
|
551
|
+
if (!dbError.message.includes(operation)) {
|
|
552
|
+
dbError.message = `${operation} operation failed on table '${table}': ${dbError.message}`;
|
|
553
|
+
}
|
|
554
|
+
if (!dbError.table) {
|
|
555
|
+
dbError.table = table;
|
|
556
|
+
}
|
|
557
|
+
return dbError;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
function createSqliteAdapter(config) {
|
|
561
|
+
return new SqliteAdapter(config);
|
|
562
|
+
}
|
|
563
|
+
function isSqliteAdapter(value) {
|
|
564
|
+
return value instanceof SqliteAdapter;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
exports.SqliteAdapter = SqliteAdapter;
|
|
568
|
+
exports.VERSION = VERSION;
|
|
569
|
+
exports.createSqliteAdapter = createSqliteAdapter;
|
|
570
|
+
exports.isSqliteAdapter = isSqliteAdapter;
|
|
571
|
+
//# sourceMappingURL=index.cjs.map
|
|
572
|
+
//# sourceMappingURL=index.cjs.map
|