@remix-run/data-table-sqlite 0.5.1 → 0.6.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/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # data-table-sqlite
2
2
 
3
- SQLite adapter for [`remix/data-table`](https://github.com/remix-run/remix/tree/main/packages/data-table). Use this package when you want `data-table` APIs backed by a synchronous SQLite client.
3
+ SQLite database driver for [`remix/data-table`](https://github.com/remix-run/remix/tree/main/packages/data-table), backed by a synchronous SQLite client.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Native Runtime SQLite Support**: Works with Node's `node:sqlite` `DatabaseSync`, Bun's `bun:sqlite` `Database`, and compatible synchronous SQLite clients
7
+ - **Native Runtime SQLite Support**: Opens a configured filename with Node's `node:sqlite` or Bun's `bun:sqlite`, or uses a compatible synchronous SQLite client
8
8
  - **Full `data-table` API Support**: Queries, relations, writes, and transactions
9
- - **Adapter-Owned Compiler**: SQL compilation lives in this adapter, with optional shared pure helpers from `data-table`
9
+ - **SQLite Compiler**: SQL compilation is handled automatically for SQLite
10
10
  - **Multi-Statement Migrations**: `executeScript()` runs `up.sql` / `down.sql` files via `Database.exec()`
11
11
  - **SQLite Capabilities Enabled By Default**:
12
12
  - `returning: true`
@@ -23,31 +23,42 @@ npm i remix
23
23
 
24
24
  ## Usage
25
25
 
26
- ### Node
27
-
28
26
  ```ts
29
- import { DatabaseSync } from 'node:sqlite'
30
- import { createDatabase } from 'remix/data-table'
31
- import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
27
+ import { createSqliteDatabase } from 'remix/data-table/sqlite'
32
28
 
33
- let sqlite = new DatabaseSync('app.db')
34
- let db = createDatabase(createSqliteDatabaseAdapter(sqlite))
29
+ let db = createSqliteDatabase({
30
+ filename: 'app.db',
31
+ foreignKeys: true,
32
+ })
35
33
  ```
36
34
 
37
- ### Bun
35
+ The config-backed database uses `node:sqlite` in Node.js and `bun:sqlite` in Bun. It supports `db.wipe()` and `db.reset()` because it can close and reopen the database file. Call `await db.close()` during application shutdown to release the connection and its file handle.
36
+
37
+ Foreign key enforcement defaults to off on every runtime. When `foreignKeys` is enabled, the database restores foreign key enforcement each time it opens the connection, including after destructive lifecycle operations.
38
+
39
+ The database also applies `pragma busy_timeout = 5000` whenever it opens the connection, so writes wait for a locked database instead of failing immediately with `SQLITE_BUSY`. Use `busyTimeout` to override the timeout in milliseconds (`0` disables the wait).
40
+
41
+ You may also pass an existing synchronous client when your application owns its lifecycle:
38
42
 
39
43
  ```ts
40
44
  import { Database } from 'bun:sqlite'
41
- import { createDatabase } from 'remix/data-table'
42
- import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
45
+ import { createSqliteDatabase } from 'remix/data-table/sqlite'
43
46
 
44
47
  let sqlite = new Database('app.db')
45
- let db = createDatabase(createSqliteDatabaseAdapter(sqlite))
48
+ let db = createSqliteDatabase(sqlite)
49
+
50
+ // Leaves the supplied client open.
51
+ await db.close()
52
+
53
+ // The application closes the client it owns.
54
+ sqlite.close()
46
55
  ```
47
56
 
57
+ Destructive lifecycle methods are unavailable when you pass an existing client.
58
+
48
59
  This is a good fit for local development, embedded deployments, and single-node services. Import any driver-specific types you need directly from your runtime's SQLite module.
49
60
 
50
- ## Adapter Capabilities
61
+ ## Database Capabilities
51
62
 
52
63
  `data-table-sqlite` reports this capability set by default:
53
64
 
@@ -59,23 +70,30 @@ This is a good fit for local development, embedded deployments, and single-node
59
70
 
60
71
  ## Advanced Usage
61
72
 
73
+ ### Destructive Lifecycle And Locking
74
+
75
+ `db.wipe()` and `db.reset()` assume a single process owns the database file. Stop other processes before wiping: on POSIX systems another process keeps writing to the deleted inode, and on Windows an open handle blocks deletion entirely. Wiping removes the `-wal`, `-shm`, and `-journal` sidecar files along with the main database file so a freshly created database never associates with stale sidecars.
76
+
77
+ SQLite migrations run without a cross-process migration lock (`migrationLock: false`), so run migrations from one process at a time.
78
+
79
+ `filename` resolves against the current working directory — for `remix db` commands, wherever you invoke the CLI. Prefer absolute paths or paths derived from `import.meta.dirname`.
80
+
62
81
  ### In-Memory Database For Tests
63
82
 
64
83
  ```ts
65
84
  import { DatabaseSync } from 'node:sqlite'
66
- import { createDatabase } from 'remix/data-table'
67
- import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
85
+ import { createSqliteDatabase } from 'remix/data-table/sqlite'
68
86
 
69
87
  let sqlite = new DatabaseSync(':memory:')
70
- let db = createDatabase(createSqliteDatabaseAdapter(sqlite))
88
+ let db = createSqliteDatabase(sqlite)
71
89
  ```
72
90
 
73
91
  ## Related Packages
74
92
 
75
93
  - [`data-table`](https://github.com/remix-run/remix/tree/main/packages/data-table) - Core query/relations API
76
94
  - [`data-schema`](https://github.com/remix-run/remix/tree/main/packages/data-schema) - Schema parsing and validation
77
- - [`data-table-postgres`](https://github.com/remix-run/remix/tree/main/packages/data-table-postgres) - PostgreSQL adapter
78
- - [`data-table-mysql`](https://github.com/remix-run/remix/tree/main/packages/data-table-mysql) - MySQL adapter
95
+ - [`data-table-postgres`](https://github.com/remix-run/remix/tree/main/packages/data-table-postgres) - PostgreSQL database driver
96
+ - [`data-table-mysql`](https://github.com/remix-run/remix/tree/main/packages/data-table-mysql) - MySQL database driver
79
97
 
80
98
  ## License
81
99
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { createSqliteDatabaseAdapter, SqliteDatabaseAdapter } from './lib/adapter.ts';
2
- export type { SqliteDatabase, SqliteRunResult, SqliteStatement } from './lib/adapter.ts';
1
+ export { createSqliteDatabase, SqliteDatabase } from './lib/database.ts';
2
+ export type { SqliteDatabaseClient, SqliteDatabaseConfig, SqliteRunResult, SqliteStatement, } from './lib/driver.ts';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AACrF,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACxE,YAAY,EACV,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,EACf,eAAe,GAChB,MAAM,iBAAiB,CAAA"}
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { createSqliteDatabaseAdapter, SqliteDatabaseAdapter } from "./lib/adapter.js";
1
+ export { createSqliteDatabase, SqliteDatabase } from './lib/database.js';
@@ -0,0 +1,29 @@
1
+ import { Database, type DatabaseOptions } from '@remix-run/data-table';
2
+ import { type SqliteDatabaseClient, type SqliteDatabaseConfig } from './driver.ts';
3
+ /** A {@link Database} backed by SQLite. */
4
+ export declare class SqliteDatabase extends Database<'sqlite'> {
5
+ /**
6
+ * Creates a SQLite-backed database.
7
+ * @param input SQLite configuration or synchronous database client.
8
+ * @param options Database runtime options.
9
+ */
10
+ constructor(input: SqliteDatabaseClient | SqliteDatabaseConfig, options?: DatabaseOptions);
11
+ }
12
+ /**
13
+ * Creates a SQLite-backed database.
14
+ *
15
+ * @param input SQLite configuration or synchronous database client.
16
+ * @param options Database runtime options.
17
+ * @returns A SQLite database.
18
+ * @example
19
+ * ```ts
20
+ * import { createSqliteDatabase } from 'remix/data-table/sqlite'
21
+ *
22
+ * let db = createSqliteDatabase({
23
+ * filename: './data/app.db',
24
+ * foreignKeys: true,
25
+ * })
26
+ * ```
27
+ */
28
+ export declare function createSqliteDatabase(input: SqliteDatabaseClient | SqliteDatabaseConfig, options?: DatabaseOptions): SqliteDatabase;
29
+ //# sourceMappingURL=database.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/lib/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAEtE,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EAC1B,MAAM,aAAa,CAAA;AAEpB,2CAA2C;AAC3C,qBAAa,cAAe,SAAQ,QAAQ,CAAC,QAAQ,CAAC;IACpD;;;;OAIG;IACH,YAAY,KAAK,EAAE,oBAAoB,GAAG,oBAAoB,EAAE,OAAO,GAAE,eAAoB,EAE5F;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,oBAAoB,GAAG,oBAAoB,EAClD,OAAO,GAAE,eAAoB,GAC5B,cAAc,CAEhB"}
@@ -0,0 +1,32 @@
1
+ import { Database } from '@remix-run/data-table';
2
+ import { SqliteDatabaseDriver, } from './driver.js';
3
+ /** A {@link Database} backed by SQLite. */
4
+ export class SqliteDatabase extends Database {
5
+ /**
6
+ * Creates a SQLite-backed database.
7
+ * @param input SQLite configuration or synchronous database client.
8
+ * @param options Database runtime options.
9
+ */
10
+ constructor(input, options = {}) {
11
+ super(new SqliteDatabaseDriver(input), options);
12
+ }
13
+ }
14
+ /**
15
+ * Creates a SQLite-backed database.
16
+ *
17
+ * @param input SQLite configuration or synchronous database client.
18
+ * @param options Database runtime options.
19
+ * @returns A SQLite database.
20
+ * @example
21
+ * ```ts
22
+ * import { createSqliteDatabase } from 'remix/data-table/sqlite'
23
+ *
24
+ * let db = createSqliteDatabase({
25
+ * filename: './data/app.db',
26
+ * foreignKeys: true,
27
+ * })
28
+ * ```
29
+ */
30
+ export function createSqliteDatabase(input, options = {}) {
31
+ return new SqliteDatabase(input, options);
32
+ }
@@ -1,16 +1,33 @@
1
- import type { DataManipulationRequest, DataManipulationResult, DataManipulationOperation, DatabaseAdapter, SqlStatement, TableRef, TransactionOptions, TransactionToken } from '@remix-run/data-table';
1
+ import type { DataManipulationOperation, DataManipulationRequest, DataManipulationResult, DatabaseDriver, SqlStatement, TableRef, TransactionOptions, TransactionToken } from '@remix-run/data-table';
2
2
  /**
3
- * Synchronous SQLite database client accepted by the sqlite adapter.
3
+ * Synchronous SQLite client accepted by `createSqliteDatabase()`.
4
4
  *
5
5
  * This matches the shared surface of Node's `node:sqlite` `DatabaseSync`, Bun's `bun:sqlite`
6
6
  * `Database`, and compatible synchronous SQLite clients.
7
7
  */
8
- export interface SqliteDatabase {
8
+ export interface SqliteDatabaseClient {
9
9
  prepare(sql: string): SqliteStatement;
10
10
  exec(sql: string): unknown;
11
+ close?: () => void;
12
+ }
13
+ /** Configuration for a SQLite database created by `createSqliteDatabase()`. */
14
+ export interface SqliteDatabaseConfig {
15
+ /** SQLite database filename or `:memory:` for an in-memory database. */
16
+ filename: string;
17
+ /**
18
+ * Enables SQLite foreign key enforcement whenever the database opens a connection.
19
+ * Defaults to `false` (enforcement off) on every runtime, including Node.js where
20
+ * `node:sqlite` would otherwise enable it by default.
21
+ */
22
+ foreignKeys?: boolean;
23
+ /**
24
+ * SQLite `busy_timeout` in milliseconds, applied whenever the database opens a connection.
25
+ * Defaults to `5000`. Set `0` to fail immediately when another process holds a write lock.
26
+ */
27
+ busyTimeout?: number;
11
28
  }
12
29
  /**
13
- * Prepared statement shape used by {@link SqliteDatabase}.
30
+ * Prepared statement shape used by {@link SqliteDatabaseClient}.
14
31
  */
15
32
  export interface SqliteStatement {
16
33
  all(...values: unknown[]): unknown[];
@@ -28,25 +45,25 @@ export interface SqliteRunResult {
28
45
  lastInsertRowid: unknown;
29
46
  }
30
47
  /**
31
- * `DatabaseAdapter` implementation for synchronous SQLite clients.
48
+ * SQLite database driver backed by a synchronous SQLite client.
32
49
  */
33
- export declare class SqliteDatabaseAdapter implements DatabaseAdapter {
50
+ export declare class SqliteDatabaseDriver implements DatabaseDriver<'sqlite'> {
34
51
  #private;
35
52
  /**
36
- * The SQL dialect identifier reported by this adapter.
53
+ * The SQL dialect identifier reported by this database.
37
54
  */
38
- dialect: string;
55
+ get dialect(): 'sqlite';
39
56
  /**
40
- * Feature flags describing the sqlite behaviors supported by this adapter.
57
+ * Feature flags describing the SQLite behaviors supported by this database.
41
58
  */
42
- capabilities: {
43
- returning: boolean;
44
- savepoints: boolean;
45
- upsert: boolean;
46
- transactionalDdl: boolean;
47
- migrationLock: boolean;
48
- };
49
- constructor(database: SqliteDatabase);
59
+ get capabilities(): Readonly<{
60
+ returning: true;
61
+ savepoints: true;
62
+ upsert: true;
63
+ transactionalDdl: true;
64
+ migrationLock: false;
65
+ }>;
66
+ constructor(input: SqliteDatabaseClient | SqliteDatabaseConfig);
50
67
  /**
51
68
  * Compiles a data-manipulation operation to sqlite SQL statements.
52
69
  * @param operation Operation to compile.
@@ -81,6 +98,20 @@ export declare class SqliteDatabaseAdapter implements DatabaseAdapter {
81
98
  * @returns `true` when the column exists.
82
99
  */
83
100
  hasColumn(table: TableRef, column: string, transaction?: TransactionToken): Promise<boolean>;
101
+ /**
102
+ * Destructively recreates the configured SQLite database.
103
+ * @returns A promise that resolves when the database is ready for use.
104
+ */
105
+ wipe(): Promise<void>;
106
+ /**
107
+ * Closes a database connection created from configuration.
108
+ *
109
+ * Config-backed databases keep an open handle that locks the database file on
110
+ * Windows until it is closed, so callers that need to move or delete the file
111
+ * should close the database first. Supplied clients remain caller-owned. Safe
112
+ * to call more than once.
113
+ */
114
+ close(): void;
84
115
  /**
85
116
  * Starts a sqlite transaction.
86
117
  * @param options Transaction options.
@@ -121,20 +152,4 @@ export declare class SqliteDatabaseAdapter implements DatabaseAdapter {
121
152
  */
122
153
  releaseSavepoint(token: TransactionToken, name: string): Promise<void>;
123
154
  }
124
- /**
125
- * Creates a sqlite `DatabaseAdapter`.
126
- * @param database Synchronous SQLite database client.
127
- * @returns A configured sqlite adapter.
128
- * @example
129
- * ```ts
130
- * import { DatabaseSync } from 'node:sqlite'
131
- * import { createDatabase } from 'remix/data-table'
132
- * import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
133
- *
134
- * let sqlite = new DatabaseSync('./data/app.db')
135
- * let adapter = createSqliteDatabaseAdapter(sqlite)
136
- * let db = createDatabase(adapter)
137
- * ```
138
- */
139
- export declare function createSqliteDatabaseAdapter(database: SqliteDatabase): SqliteDatabaseAdapter;
140
- //# sourceMappingURL=adapter.d.ts.map
155
+ //# sourceMappingURL=driver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/lib/driver.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,yBAAyB,EACzB,uBAAuB,EACvB,sBAAsB,EACtB,cAAc,EACd,YAAY,EACZ,QAAQ,EACR,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,uBAAuB,CAAA;AAK9B;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAAA;IACrC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAC1B,KAAK,CAAC,EAAE,MAAM,IAAI,CAAA;CACnB;AAkDD,+EAA+E;AAC/E,MAAM,WAAW,oBAAoB;IACnC,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAAA;IACpC,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAA;IAClC,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,eAAe,CAAA;IAC1C,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,OAAO,EAAE,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;CACzB;AAED;;GAEG;AACH,qBAAa,oBAAqB,YAAW,cAAc,CAAC,QAAQ,CAAC;;IACnE;;OAEG;IACH,IAAI,OAAO,IAAI,QAAQ,CAEtB;IAED;;OAEG;IACH,IAAI,YAAY;;;;;;OAEf;IAQD,YAAY,KAAK,EAAE,oBAAoB,GAAG,oBAAoB,EAO7D;IAED;;;;OAIG;IACH,UAAU,CAAC,SAAS,EAAE,yBAAyB,GAAG,YAAY,EAAE,CAG/D;IAED;;;;OAIG;IACG,OAAO,CAAC,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAuC/E;IAED;;;;;OAKG;IACG,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ9E;IAED;;;;;OAKG;IACG,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,WAAW,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAgBhF;IAED;;;;;;OAMG;IACG,SAAS,CACb,KAAK,EAAE,QAAQ,EACf,MAAM,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,gBAAgB,GAC7B,OAAO,CAAC,OAAO,CAAC,CAclB;IAED;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAqB1B;IAED;;;;;;;OAOG;IACH,KAAK,IAAI,IAAI,CAKZ;IAED;;;;OAIG;IACG,gBAAgB,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAc9E;IAED;;;;OAIG;IACG,iBAAiB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsB9D;IAED;;;;OAIG;IACG,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBhE;IAED;;;;;OAKG;IACG,eAAe,CAAC,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1E;IAED;;;;;OAKG;IACG,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG9E;IAED;;;;;OAKG;IACG,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3E;CAqDF"}
@@ -1,29 +1,68 @@
1
+ import { dirname } from 'node:path';
2
+ import { mkdir, rm } from 'node:fs/promises';
3
+ import { setTimeout } from 'node:timers/promises';
1
4
  import { getTablePrimaryKey } from '@remix-run/data-table';
2
- import { compileSqliteOperation } from "./sql-compiler.js";
5
+ import { compileSqliteOperation } from './sql-compiler.js';
6
+ const sqliteCapabilities = Object.freeze({
7
+ returning: true,
8
+ savepoints: true,
9
+ upsert: true,
10
+ transactionalDdl: true,
11
+ migrationLock: false,
12
+ });
13
+ let loadedDriverConstructor;
14
+ // The runtime driver loads lazily on config-backed construction so client-backed databases
15
+ // keep working in environments that cannot resolve node:sqlite or bun:sqlite at import time,
16
+ // and so bundlers never try to resolve those specifiers statically.
17
+ function loadSqliteDatabaseConstructor() {
18
+ if (!loadedDriverConstructor) {
19
+ if ('Bun' in globalThis) {
20
+ // import.meta.require is Bun's synchronous require for ES modules; Bun does not
21
+ // implement process.getBuiltinModule
22
+ let importMeta = import.meta;
23
+ let driver = importMeta.require?.('bun:sqlite');
24
+ loadedDriverConstructor = driver?.Database;
25
+ }
26
+ else {
27
+ // process.getBuiltinModule loads node:sqlite synchronously (Node.js 22.3+)
28
+ let driver = globalThis.process?.getBuiltinModule?.('node:sqlite');
29
+ loadedDriverConstructor = driver?.DatabaseSync;
30
+ }
31
+ if (!loadedDriverConstructor) {
32
+ throw new Error('SQLite config-based construction requires node:sqlite (Node.js 22.5+) or bun:sqlite; pass a SQLite database client instead');
33
+ }
34
+ }
35
+ return loadedDriverConstructor;
36
+ }
3
37
  /**
4
- * `DatabaseAdapter` implementation for synchronous SQLite clients.
38
+ * SQLite database driver backed by a synchronous SQLite client.
5
39
  */
6
- export class SqliteDatabaseAdapter {
40
+ export class SqliteDatabaseDriver {
7
41
  /**
8
- * The SQL dialect identifier reported by this adapter.
42
+ * The SQL dialect identifier reported by this database.
9
43
  */
10
- dialect = 'sqlite';
44
+ get dialect() {
45
+ return 'sqlite';
46
+ }
11
47
  /**
12
- * Feature flags describing the sqlite behaviors supported by this adapter.
48
+ * Feature flags describing the SQLite behaviors supported by this database.
13
49
  */
14
- capabilities;
50
+ get capabilities() {
51
+ return sqliteCapabilities;
52
+ }
53
+ #config;
15
54
  #database;
55
+ #databaseOpen = true;
16
56
  #transactions = new Set();
17
57
  #transactionCounter = 0;
18
- constructor(database) {
19
- this.#database = database;
20
- this.capabilities = {
21
- returning: true,
22
- savepoints: true,
23
- upsert: true,
24
- transactionalDdl: true,
25
- migrationLock: false,
26
- };
58
+ constructor(input) {
59
+ if (isSqliteDatabase(input)) {
60
+ this.#database = input;
61
+ }
62
+ else {
63
+ this.#config = input;
64
+ this.#database = openSqliteDatabase(input);
65
+ }
27
66
  }
28
67
  /**
29
68
  * Compiles a data-manipulation operation to sqlite SQL statements.
@@ -40,6 +79,7 @@ export class SqliteDatabaseAdapter {
40
79
  * @returns Execution result.
41
80
  */
42
81
  async execute(request) {
82
+ this.#assertDatabaseOpen();
43
83
  if (request.operation.kind === 'insertMany' && request.operation.values.length === 0) {
44
84
  return {
45
85
  affectedRows: 0,
@@ -77,6 +117,7 @@ export class SqliteDatabaseAdapter {
77
117
  * @returns A promise that resolves once execution completes.
78
118
  */
79
119
  async executeScript(sql, transaction) {
120
+ this.#assertDatabaseOpen();
80
121
  if (transaction) {
81
122
  this.#assertTransaction(transaction);
82
123
  }
@@ -89,6 +130,7 @@ export class SqliteDatabaseAdapter {
89
130
  * @returns `true` when the table exists.
90
131
  */
91
132
  async hasTable(table, transaction) {
133
+ this.#assertDatabaseOpen();
92
134
  if (transaction) {
93
135
  this.#assertTransaction(transaction);
94
136
  }
@@ -96,8 +138,9 @@ export class SqliteDatabaseAdapter {
96
138
  ? quoteIdentifier(table.schema) + '.sqlite_master'
97
139
  : 'sqlite_master';
98
140
  let statement = this.#database.prepare('select 1 from ' + masterTable + ' where type = ? and name = ? limit 1');
141
+ // node:sqlite returns `undefined` for a missing row while bun:sqlite returns `null`
99
142
  let row = statement.get('table', table.name);
100
- return row !== undefined;
143
+ return row != null;
101
144
  }
102
145
  /**
103
146
  * Checks whether a column exists in sqlite.
@@ -107,6 +150,7 @@ export class SqliteDatabaseAdapter {
107
150
  * @returns `true` when the column exists.
108
151
  */
109
152
  async hasColumn(table, column, transaction) {
153
+ this.#assertDatabaseOpen();
110
154
  if (transaction) {
111
155
  this.#assertTransaction(transaction);
112
156
  }
@@ -115,12 +159,52 @@ export class SqliteDatabaseAdapter {
115
159
  let rows = statement.all();
116
160
  return rows.some((row) => row.name === column);
117
161
  }
162
+ /**
163
+ * Destructively recreates the configured SQLite database.
164
+ * @returns A promise that resolves when the database is ready for use.
165
+ */
166
+ async wipe() {
167
+ let config = this.#configOrThrow('wipe');
168
+ this.#assertNoOpenTransactions('wipe');
169
+ this.#closeDatabase();
170
+ if (config.filename === ':memory:') {
171
+ this.#replaceDatabase();
172
+ return;
173
+ }
174
+ try {
175
+ await mkdir(dirname(config.filename), { recursive: true });
176
+ await removeDatabaseFile(config.filename);
177
+ // SQLite associates a database file with -wal/-shm/-journal sidecars by path, so
178
+ // stale sidecars left next to a freshly created database are a corruption vector
179
+ await removeDatabaseFile(config.filename + '-wal');
180
+ await removeDatabaseFile(config.filename + '-shm');
181
+ await removeDatabaseFile(config.filename + '-journal');
182
+ }
183
+ finally {
184
+ this.#replaceDatabase();
185
+ }
186
+ }
187
+ /**
188
+ * Closes a database connection created from configuration.
189
+ *
190
+ * Config-backed databases keep an open handle that locks the database file on
191
+ * Windows until it is closed, so callers that need to move or delete the file
192
+ * should close the database first. Supplied clients remain caller-owned. Safe
193
+ * to call more than once.
194
+ */
195
+ close() {
196
+ this.#assertNoOpenTransactions('close');
197
+ if (this.#config) {
198
+ this.#closeDatabase();
199
+ }
200
+ }
118
201
  /**
119
202
  * Starts a sqlite transaction.
120
203
  * @param options Transaction options.
121
204
  * @returns Transaction token.
122
205
  */
123
206
  async beginTransaction(options) {
207
+ this.#assertDatabaseOpen();
124
208
  if (options?.isolationLevel === 'read uncommitted') {
125
209
  this.#database.exec('pragma read_uncommitted = true');
126
210
  }
@@ -137,8 +221,30 @@ export class SqliteDatabaseAdapter {
137
221
  */
138
222
  async commitTransaction(token) {
139
223
  this.#assertTransaction(token);
140
- this.#database.exec('commit');
141
- this.#transactions.delete(token.id);
224
+ try {
225
+ this.#database.exec('commit');
226
+ }
227
+ catch (commitError) {
228
+ try {
229
+ this.#database.exec('rollback');
230
+ }
231
+ catch (rollbackError) {
232
+ let failures = [commitError, rollbackError];
233
+ try {
234
+ this.#discardUncertainConnection();
235
+ }
236
+ catch (recoveryError) {
237
+ failures.push(recoveryError);
238
+ }
239
+ throw new AggregateError(failures, 'SQLite commit and rollback both failed', {
240
+ cause: commitError,
241
+ });
242
+ }
243
+ throw commitError;
244
+ }
245
+ finally {
246
+ this.#transactions.delete(token.id);
247
+ }
142
248
  }
143
249
  /**
144
250
  * Rolls back an open sqlite transaction.
@@ -147,8 +253,21 @@ export class SqliteDatabaseAdapter {
147
253
  */
148
254
  async rollbackTransaction(token) {
149
255
  this.#assertTransaction(token);
150
- this.#database.exec('rollback');
151
- this.#transactions.delete(token.id);
256
+ try {
257
+ this.#database.exec('rollback');
258
+ }
259
+ catch (rollbackError) {
260
+ try {
261
+ this.#discardUncertainConnection();
262
+ }
263
+ catch (recoveryError) {
264
+ throw new AggregateError([rollbackError, recoveryError], 'SQLite rollback and connection cleanup both failed', { cause: rollbackError });
265
+ }
266
+ throw rollbackError;
267
+ }
268
+ finally {
269
+ this.#transactions.delete(token.id);
270
+ }
152
271
  }
153
272
  /**
154
273
  * Creates a savepoint in an open sqlite transaction.
@@ -180,29 +299,90 @@ export class SqliteDatabaseAdapter {
180
299
  this.#assertTransaction(token);
181
300
  this.#database.exec('release savepoint ' + quoteIdentifier(name));
182
301
  }
302
+ #replaceDatabase() {
303
+ if (this.#config) {
304
+ this.#database = openSqliteDatabase(this.#config);
305
+ this.#databaseOpen = true;
306
+ }
307
+ }
308
+ #closeDatabase() {
309
+ if (this.#databaseOpen) {
310
+ this.#databaseOpen = false;
311
+ this.#database.close?.();
312
+ }
313
+ }
314
+ #discardUncertainConnection() {
315
+ if (this.#config) {
316
+ this.#closeDatabase();
317
+ this.#replaceDatabase();
318
+ }
319
+ else {
320
+ // The supplied client remains caller-owned, but this wrapper cannot safely
321
+ // reuse a connection whose transaction state is unknown.
322
+ this.#databaseOpen = false;
323
+ }
324
+ }
325
+ #configOrThrow(method) {
326
+ if (!this.#config) {
327
+ throw new Error('SQLite database ' + method + '() requires config-based construction');
328
+ }
329
+ return this.#config;
330
+ }
331
+ #assertNoOpenTransactions(method) {
332
+ if (this.#transactions.size > 0) {
333
+ throw new Error('SQLite database cannot ' + method + ' while transactions are open');
334
+ }
335
+ }
183
336
  #assertTransaction(token) {
337
+ this.#assertDatabaseOpen();
184
338
  if (!this.#transactions.has(token.id)) {
185
339
  throw new Error('Unknown transaction token: ' + token.id);
186
340
  }
187
341
  }
342
+ #assertDatabaseOpen() {
343
+ if (!this.#databaseOpen) {
344
+ throw new Error('SQLite database is closed');
345
+ }
346
+ }
188
347
  }
189
- /**
190
- * Creates a sqlite `DatabaseAdapter`.
191
- * @param database Synchronous SQLite database client.
192
- * @returns A configured sqlite adapter.
193
- * @example
194
- * ```ts
195
- * import { DatabaseSync } from 'node:sqlite'
196
- * import { createDatabase } from 'remix/data-table'
197
- * import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
198
- *
199
- * let sqlite = new DatabaseSync('./data/app.db')
200
- * let adapter = createSqliteDatabaseAdapter(sqlite)
201
- * let db = createDatabase(adapter)
202
- * ```
203
- */
204
- export function createSqliteDatabaseAdapter(database) {
205
- return new SqliteDatabaseAdapter(database);
348
+ const REMOVE_RETRIES = 10;
349
+ const REMOVE_RETRY_DELAY_MS = 100;
350
+ async function removeDatabaseFile(filename) {
351
+ // Windows keeps a just-closed database file locked for a short window (deferred handle
352
+ // release, antivirus scans), so removal is retried with a linear backoff
353
+ for (let attempt = 0;; attempt++) {
354
+ try {
355
+ await rm(filename, { force: true });
356
+ return;
357
+ }
358
+ catch (error) {
359
+ if (attempt >= REMOVE_RETRIES || !isRetryableRemoveError(error)) {
360
+ throw error;
361
+ }
362
+ }
363
+ await setTimeout(REMOVE_RETRY_DELAY_MS * (attempt + 1));
364
+ }
365
+ }
366
+ function isRetryableRemoveError(error) {
367
+ let code = error?.code;
368
+ return code === 'EBUSY' || code === 'EPERM' || code === 'EMFILE' || code === 'ENFILE';
369
+ }
370
+ function openSqliteDatabase(config) {
371
+ let SqliteDatabaseConstructor = loadSqliteDatabaseConstructor();
372
+ let database = new SqliteDatabaseConstructor(config.filename);
373
+ // node:sqlite enables foreign keys by default while bun:sqlite follows SQLite's default
374
+ // (off), so set the pragma explicitly to make the option authoritative on both runtimes
375
+ database.exec('pragma foreign_keys = ' + (config.foreignKeys ? 'on' : 'off'));
376
+ // node:sqlite defaults to busy_timeout 0, which fails immediately with SQLITE_BUSY when
377
+ // another process holds a write lock
378
+ database.exec('pragma busy_timeout = ' + String(config.busyTimeout ?? 5000));
379
+ return database;
380
+ }
381
+ function isSqliteDatabase(input) {
382
+ return ('prepare' in input &&
383
+ typeof input.prepare === 'function' &&
384
+ 'exec' in input &&
385
+ typeof input.exec === 'function');
206
386
  }
207
387
  function normalizeRows(rows) {
208
388
  return rows.map((row) => {
@@ -1,3 +1,4 @@
1
- import type { DataManipulationOperation, SqlStatement } from '@remix-run/data-table';
1
+ import type { SqlStatement } from '@remix-run/data-table';
2
+ import type { DataManipulationOperation } from '@remix-run/data-table';
2
3
  export declare function compileSqliteOperation(operation: DataManipulationOperation): SqlStatement;
3
4
  //# sourceMappingURL=sql-compiler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sql-compiler.d.ts","sourceRoot":"","sources":["../../src/lib/sql-compiler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,yBAAyB,EAAa,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAe/F,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,yBAAyB,GAAG,YAAY,CAuGzF"}
1
+ {"version":3,"file":"sql-compiler.d.ts","sourceRoot":"","sources":["../../src/lib/sql-compiler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAa,YAAY,EAAE,MAAM,uBAAuB,CAAA;AACpE,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,uBAAuB,CAAA;AAetE,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,yBAAyB,GAAG,YAAY,CAuGzF"}
@@ -24,8 +24,8 @@ export function compileSqliteOperation(operation) {
24
24
  compileGroupByClause(operation.groupBy) +
25
25
  compileHavingClause(operation.having, context) +
26
26
  compileOrderByClause(operation.orderBy) +
27
- compileLimitClause(operation.limit) +
28
- compileOffsetClause(operation.offset),
27
+ compileLimitClause(operation.limit, context) +
28
+ compileOffsetClause(operation.offset, context),
29
29
  values: context.values,
30
30
  };
31
31
  }
@@ -218,17 +218,17 @@ function compileOrderByClause(orderBy) {
218
218
  .map((clause) => quotePath(clause.column) + ' ' + clause.direction.toUpperCase())
219
219
  .join(', '));
220
220
  }
221
- function compileLimitClause(limit) {
221
+ function compileLimitClause(limit, context) {
222
222
  if (limit === undefined) {
223
223
  return '';
224
224
  }
225
- return ' limit ' + String(limit);
225
+ return ' limit ' + pushValue(context, limit);
226
226
  }
227
- function compileOffsetClause(offset) {
227
+ function compileOffsetClause(offset, context) {
228
228
  if (offset === undefined) {
229
229
  return '';
230
230
  }
231
- return ' offset ' + String(offset);
231
+ return ' offset ' + pushValue(context, offset);
232
232
  }
233
233
  function compileReturningClause(returning) {
234
234
  if (!returning) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@remix-run/data-table-sqlite",
3
- "version": "0.5.1",
4
- "description": "SQLite adapter for remix/data-table",
3
+ "version": "0.6.0",
4
+ "description": "SQLite database implementation for remix/data-table",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -28,13 +28,13 @@
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^24.6.0",
31
- "@typescript/native-preview": "7.0.0-dev.20251125.1",
32
- "@remix-run/assert": "0.2.1",
33
- "@remix-run/data-table": "0.3.0",
34
- "@remix-run/test": "0.4.2"
31
+ "typescript": "^7.0.2",
32
+ "@remix-run/test": "0.6.0",
33
+ "@remix-run/data-table": "0.4.0",
34
+ "@remix-run/assert": "0.3.0"
35
35
  },
36
36
  "dependencies": {
37
- "@remix-run/data-table": "^0.3.0"
37
+ "@remix-run/data-table": "^0.4.0"
38
38
  },
39
39
  "keywords": [
40
40
  "remix",
@@ -44,11 +44,11 @@
44
44
  "sql"
45
45
  ],
46
46
  "scripts": {
47
- "build": "tsgo -p tsconfig.build.json",
47
+ "build": "tsc -p tsconfig.build.json",
48
48
  "clean": "git clean -fdX",
49
- "test": "remix-test",
50
- "test:bun": "bun x --bun remix-test",
51
- "test:coverage": "remix-test --coverage",
52
- "typecheck": "tsgo --noEmit"
49
+ "test": "remix test",
50
+ "test:bun": "bun x --bun remix test",
51
+ "test:coverage": "remix test --coverage",
52
+ "typecheck": "tsc --noEmit"
53
53
  }
54
54
  }
package/src/index.ts CHANGED
@@ -1,2 +1,7 @@
1
- export { createSqliteDatabaseAdapter, SqliteDatabaseAdapter } from './lib/adapter.ts'
2
- export type { SqliteDatabase, SqliteRunResult, SqliteStatement } from './lib/adapter.ts'
1
+ export { createSqliteDatabase, SqliteDatabase } from './lib/database.ts'
2
+ export type {
3
+ SqliteDatabaseClient,
4
+ SqliteDatabaseConfig,
5
+ SqliteRunResult,
6
+ SqliteStatement,
7
+ } from './lib/driver.ts'
@@ -0,0 +1,42 @@
1
+ import { Database, type DatabaseOptions } from '@remix-run/data-table'
2
+
3
+ import {
4
+ SqliteDatabaseDriver,
5
+ type SqliteDatabaseClient,
6
+ type SqliteDatabaseConfig,
7
+ } from './driver.ts'
8
+
9
+ /** A {@link Database} backed by SQLite. */
10
+ export class SqliteDatabase extends Database<'sqlite'> {
11
+ /**
12
+ * Creates a SQLite-backed database.
13
+ * @param input SQLite configuration or synchronous database client.
14
+ * @param options Database runtime options.
15
+ */
16
+ constructor(input: SqliteDatabaseClient | SqliteDatabaseConfig, options: DatabaseOptions = {}) {
17
+ super(new SqliteDatabaseDriver(input), options)
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Creates a SQLite-backed database.
23
+ *
24
+ * @param input SQLite configuration or synchronous database client.
25
+ * @param options Database runtime options.
26
+ * @returns A SQLite database.
27
+ * @example
28
+ * ```ts
29
+ * import { createSqliteDatabase } from 'remix/data-table/sqlite'
30
+ *
31
+ * let db = createSqliteDatabase({
32
+ * filename: './data/app.db',
33
+ * foreignKeys: true,
34
+ * })
35
+ * ```
36
+ */
37
+ export function createSqliteDatabase(
38
+ input: SqliteDatabaseClient | SqliteDatabaseConfig,
39
+ options: DatabaseOptions = {},
40
+ ): SqliteDatabase {
41
+ return new SqliteDatabase(input, options)
42
+ }
@@ -1,8 +1,12 @@
1
+ import { dirname } from 'node:path'
2
+ import { mkdir, rm } from 'node:fs/promises'
3
+ import { setTimeout } from 'node:timers/promises'
4
+
1
5
  import type {
6
+ DataManipulationOperation,
2
7
  DataManipulationRequest,
3
8
  DataManipulationResult,
4
- DataManipulationOperation,
5
- DatabaseAdapter,
9
+ DatabaseDriver,
6
10
  SqlStatement,
7
11
  TableRef,
8
12
  TransactionOptions,
@@ -13,18 +17,84 @@ import { getTablePrimaryKey } from '@remix-run/data-table'
13
17
  import { compileSqliteOperation } from './sql-compiler.ts'
14
18
 
15
19
  /**
16
- * Synchronous SQLite database client accepted by the sqlite adapter.
20
+ * Synchronous SQLite client accepted by `createSqliteDatabase()`.
17
21
  *
18
22
  * This matches the shared surface of Node's `node:sqlite` `DatabaseSync`, Bun's `bun:sqlite`
19
23
  * `Database`, and compatible synchronous SQLite clients.
20
24
  */
21
- export interface SqliteDatabase {
25
+ export interface SqliteDatabaseClient {
22
26
  prepare(sql: string): SqliteStatement
23
27
  exec(sql: string): unknown
28
+ close?: () => void
29
+ }
30
+
31
+ type SqliteDatabaseConstructor = {
32
+ new (path: string): SqliteDatabaseClient
33
+ }
34
+
35
+ type SqliteDriverModule = {
36
+ Database?: SqliteDatabaseConstructor
37
+ DatabaseSync?: SqliteDatabaseConstructor
38
+ }
39
+
40
+ const sqliteCapabilities = Object.freeze({
41
+ returning: true,
42
+ savepoints: true,
43
+ upsert: true,
44
+ transactionalDdl: true,
45
+ migrationLock: false,
46
+ })
47
+
48
+ let loadedDriverConstructor: SqliteDatabaseConstructor | undefined
49
+
50
+ // The runtime driver loads lazily on config-backed construction so client-backed databases
51
+ // keep working in environments that cannot resolve node:sqlite or bun:sqlite at import time,
52
+ // and so bundlers never try to resolve those specifiers statically.
53
+ function loadSqliteDatabaseConstructor(): SqliteDatabaseConstructor {
54
+ if (!loadedDriverConstructor) {
55
+ if ('Bun' in globalThis) {
56
+ // import.meta.require is Bun's synchronous require for ES modules; Bun does not
57
+ // implement process.getBuiltinModule
58
+ let importMeta = import.meta as ImportMeta & { require?: (id: string) => unknown }
59
+ let driver = importMeta.require?.('bun:sqlite') as SqliteDriverModule | undefined
60
+ loadedDriverConstructor = driver?.Database
61
+ } else {
62
+ // process.getBuiltinModule loads node:sqlite synchronously (Node.js 22.3+)
63
+ let driver = globalThis.process?.getBuiltinModule?.('node:sqlite') as
64
+ | SqliteDriverModule
65
+ | undefined
66
+ loadedDriverConstructor = driver?.DatabaseSync
67
+ }
68
+
69
+ if (!loadedDriverConstructor) {
70
+ throw new Error(
71
+ 'SQLite config-based construction requires node:sqlite (Node.js 22.5+) or bun:sqlite; pass a SQLite database client instead',
72
+ )
73
+ }
74
+ }
75
+
76
+ return loadedDriverConstructor
77
+ }
78
+
79
+ /** Configuration for a SQLite database created by `createSqliteDatabase()`. */
80
+ export interface SqliteDatabaseConfig {
81
+ /** SQLite database filename or `:memory:` for an in-memory database. */
82
+ filename: string
83
+ /**
84
+ * Enables SQLite foreign key enforcement whenever the database opens a connection.
85
+ * Defaults to `false` (enforcement off) on every runtime, including Node.js where
86
+ * `node:sqlite` would otherwise enable it by default.
87
+ */
88
+ foreignKeys?: boolean
89
+ /**
90
+ * SQLite `busy_timeout` in milliseconds, applied whenever the database opens a connection.
91
+ * Defaults to `5000`. Set `0` to fail immediately when another process holds a write lock.
92
+ */
93
+ busyTimeout?: number
24
94
  }
25
95
 
26
96
  /**
27
- * Prepared statement shape used by {@link SqliteDatabase}.
97
+ * Prepared statement shape used by {@link SqliteDatabaseClient}.
28
98
  */
29
99
  export interface SqliteStatement {
30
100
  all(...values: unknown[]): unknown[]
@@ -44,31 +114,35 @@ export interface SqliteRunResult {
44
114
  }
45
115
 
46
116
  /**
47
- * `DatabaseAdapter` implementation for synchronous SQLite clients.
117
+ * SQLite database driver backed by a synchronous SQLite client.
48
118
  */
49
- export class SqliteDatabaseAdapter implements DatabaseAdapter {
119
+ export class SqliteDatabaseDriver implements DatabaseDriver<'sqlite'> {
50
120
  /**
51
- * The SQL dialect identifier reported by this adapter.
121
+ * The SQL dialect identifier reported by this database.
52
122
  */
53
- dialect = 'sqlite'
123
+ get dialect(): 'sqlite' {
124
+ return 'sqlite'
125
+ }
54
126
 
55
127
  /**
56
- * Feature flags describing the sqlite behaviors supported by this adapter.
128
+ * Feature flags describing the SQLite behaviors supported by this database.
57
129
  */
58
- capabilities
130
+ get capabilities() {
131
+ return sqliteCapabilities
132
+ }
59
133
 
60
- #database: SqliteDatabase
134
+ #config?: SqliteDatabaseConfig
135
+ #database: SqliteDatabaseClient
136
+ #databaseOpen = true
61
137
  #transactions = new Set<string>()
62
138
  #transactionCounter = 0
63
139
 
64
- constructor(database: SqliteDatabase) {
65
- this.#database = database
66
- this.capabilities = {
67
- returning: true,
68
- savepoints: true,
69
- upsert: true,
70
- transactionalDdl: true,
71
- migrationLock: false,
140
+ constructor(input: SqliteDatabaseClient | SqliteDatabaseConfig) {
141
+ if (isSqliteDatabase(input)) {
142
+ this.#database = input
143
+ } else {
144
+ this.#config = input
145
+ this.#database = openSqliteDatabase(input)
72
146
  }
73
147
  }
74
148
 
@@ -88,6 +162,8 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
88
162
  * @returns Execution result.
89
163
  */
90
164
  async execute(request: DataManipulationRequest): Promise<DataManipulationResult> {
165
+ this.#assertDatabaseOpen()
166
+
91
167
  if (request.operation.kind === 'insertMany' && request.operation.values.length === 0) {
92
168
  return {
93
169
  affectedRows: 0,
@@ -133,6 +209,8 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
133
209
  * @returns A promise that resolves once execution completes.
134
210
  */
135
211
  async executeScript(sql: string, transaction?: TransactionToken): Promise<void> {
212
+ this.#assertDatabaseOpen()
213
+
136
214
  if (transaction) {
137
215
  this.#assertTransaction(transaction)
138
216
  }
@@ -147,6 +225,8 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
147
225
  * @returns `true` when the table exists.
148
226
  */
149
227
  async hasTable(table: TableRef, transaction?: TransactionToken): Promise<boolean> {
228
+ this.#assertDatabaseOpen()
229
+
150
230
  if (transaction) {
151
231
  this.#assertTransaction(transaction)
152
232
  }
@@ -157,8 +237,9 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
157
237
  let statement = this.#database.prepare(
158
238
  'select 1 from ' + masterTable + ' where type = ? and name = ? limit 1',
159
239
  )
240
+ // node:sqlite returns `undefined` for a missing row while bun:sqlite returns `null`
160
241
  let row = statement.get('table', table.name)
161
- return row !== undefined
242
+ return row != null
162
243
  }
163
244
 
164
245
  /**
@@ -173,6 +254,8 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
173
254
  column: string,
174
255
  transaction?: TransactionToken,
175
256
  ): Promise<boolean> {
257
+ this.#assertDatabaseOpen()
258
+
176
259
  if (transaction) {
177
260
  this.#assertTransaction(transaction)
178
261
  }
@@ -186,12 +269,56 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
186
269
  return rows.some((row) => row.name === column)
187
270
  }
188
271
 
272
+ /**
273
+ * Destructively recreates the configured SQLite database.
274
+ * @returns A promise that resolves when the database is ready for use.
275
+ */
276
+ async wipe(): Promise<void> {
277
+ let config = this.#configOrThrow('wipe')
278
+ this.#assertNoOpenTransactions('wipe')
279
+ this.#closeDatabase()
280
+
281
+ if (config.filename === ':memory:') {
282
+ this.#replaceDatabase()
283
+ return
284
+ }
285
+
286
+ try {
287
+ await mkdir(dirname(config.filename), { recursive: true })
288
+ await removeDatabaseFile(config.filename)
289
+ // SQLite associates a database file with -wal/-shm/-journal sidecars by path, so
290
+ // stale sidecars left next to a freshly created database are a corruption vector
291
+ await removeDatabaseFile(config.filename + '-wal')
292
+ await removeDatabaseFile(config.filename + '-shm')
293
+ await removeDatabaseFile(config.filename + '-journal')
294
+ } finally {
295
+ this.#replaceDatabase()
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Closes a database connection created from configuration.
301
+ *
302
+ * Config-backed databases keep an open handle that locks the database file on
303
+ * Windows until it is closed, so callers that need to move or delete the file
304
+ * should close the database first. Supplied clients remain caller-owned. Safe
305
+ * to call more than once.
306
+ */
307
+ close(): void {
308
+ this.#assertNoOpenTransactions('close')
309
+ if (this.#config) {
310
+ this.#closeDatabase()
311
+ }
312
+ }
313
+
189
314
  /**
190
315
  * Starts a sqlite transaction.
191
316
  * @param options Transaction options.
192
317
  * @returns Transaction token.
193
318
  */
194
319
  async beginTransaction(options?: TransactionOptions): Promise<TransactionToken> {
320
+ this.#assertDatabaseOpen()
321
+
195
322
  if (options?.isolationLevel === 'read uncommitted') {
196
323
  this.#database.exec('pragma read_uncommitted = true')
197
324
  }
@@ -212,8 +339,26 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
212
339
  */
213
340
  async commitTransaction(token: TransactionToken): Promise<void> {
214
341
  this.#assertTransaction(token)
215
- this.#database.exec('commit')
216
- this.#transactions.delete(token.id)
342
+ try {
343
+ this.#database.exec('commit')
344
+ } catch (commitError) {
345
+ try {
346
+ this.#database.exec('rollback')
347
+ } catch (rollbackError) {
348
+ let failures: unknown[] = [commitError, rollbackError]
349
+ try {
350
+ this.#discardUncertainConnection()
351
+ } catch (recoveryError) {
352
+ failures.push(recoveryError)
353
+ }
354
+ throw new AggregateError(failures, 'SQLite commit and rollback both failed', {
355
+ cause: commitError,
356
+ })
357
+ }
358
+ throw commitError
359
+ } finally {
360
+ this.#transactions.delete(token.id)
361
+ }
217
362
  }
218
363
 
219
364
  /**
@@ -223,8 +368,22 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
223
368
  */
224
369
  async rollbackTransaction(token: TransactionToken): Promise<void> {
225
370
  this.#assertTransaction(token)
226
- this.#database.exec('rollback')
227
- this.#transactions.delete(token.id)
371
+ try {
372
+ this.#database.exec('rollback')
373
+ } catch (rollbackError) {
374
+ try {
375
+ this.#discardUncertainConnection()
376
+ } catch (recoveryError) {
377
+ throw new AggregateError(
378
+ [rollbackError, recoveryError],
379
+ 'SQLite rollback and connection cleanup both failed',
380
+ { cause: rollbackError },
381
+ )
382
+ }
383
+ throw rollbackError
384
+ } finally {
385
+ this.#transactions.delete(token.id)
386
+ }
228
387
  }
229
388
 
230
389
  /**
@@ -260,30 +419,107 @@ export class SqliteDatabaseAdapter implements DatabaseAdapter {
260
419
  this.#database.exec('release savepoint ' + quoteIdentifier(name))
261
420
  }
262
421
 
422
+ #replaceDatabase(): void {
423
+ if (this.#config) {
424
+ this.#database = openSqliteDatabase(this.#config)
425
+ this.#databaseOpen = true
426
+ }
427
+ }
428
+
429
+ #closeDatabase(): void {
430
+ if (this.#databaseOpen) {
431
+ this.#databaseOpen = false
432
+ this.#database.close?.()
433
+ }
434
+ }
435
+
436
+ #discardUncertainConnection(): void {
437
+ if (this.#config) {
438
+ this.#closeDatabase()
439
+ this.#replaceDatabase()
440
+ } else {
441
+ // The supplied client remains caller-owned, but this wrapper cannot safely
442
+ // reuse a connection whose transaction state is unknown.
443
+ this.#databaseOpen = false
444
+ }
445
+ }
446
+
447
+ #configOrThrow(method: string): SqliteDatabaseConfig {
448
+ if (!this.#config) {
449
+ throw new Error('SQLite database ' + method + '() requires config-based construction')
450
+ }
451
+
452
+ return this.#config
453
+ }
454
+
455
+ #assertNoOpenTransactions(method: string): void {
456
+ if (this.#transactions.size > 0) {
457
+ throw new Error('SQLite database cannot ' + method + ' while transactions are open')
458
+ }
459
+ }
460
+
263
461
  #assertTransaction(token: TransactionToken): void {
462
+ this.#assertDatabaseOpen()
264
463
  if (!this.#transactions.has(token.id)) {
265
464
  throw new Error('Unknown transaction token: ' + token.id)
266
465
  }
267
466
  }
467
+
468
+ #assertDatabaseOpen(): void {
469
+ if (!this.#databaseOpen) {
470
+ throw new Error('SQLite database is closed')
471
+ }
472
+ }
268
473
  }
269
474
 
270
- /**
271
- * Creates a sqlite `DatabaseAdapter`.
272
- * @param database Synchronous SQLite database client.
273
- * @returns A configured sqlite adapter.
274
- * @example
275
- * ```ts
276
- * import { DatabaseSync } from 'node:sqlite'
277
- * import { createDatabase } from 'remix/data-table'
278
- * import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
279
- *
280
- * let sqlite = new DatabaseSync('./data/app.db')
281
- * let adapter = createSqliteDatabaseAdapter(sqlite)
282
- * let db = createDatabase(adapter)
283
- * ```
284
- */
285
- export function createSqliteDatabaseAdapter(database: SqliteDatabase): SqliteDatabaseAdapter {
286
- return new SqliteDatabaseAdapter(database)
475
+ const REMOVE_RETRIES = 10
476
+ const REMOVE_RETRY_DELAY_MS = 100
477
+
478
+ async function removeDatabaseFile(filename: string): Promise<void> {
479
+ // Windows keeps a just-closed database file locked for a short window (deferred handle
480
+ // release, antivirus scans), so removal is retried with a linear backoff
481
+ for (let attempt = 0; ; attempt++) {
482
+ try {
483
+ await rm(filename, { force: true })
484
+ return
485
+ } catch (error) {
486
+ if (attempt >= REMOVE_RETRIES || !isRetryableRemoveError(error)) {
487
+ throw error
488
+ }
489
+ }
490
+
491
+ await setTimeout(REMOVE_RETRY_DELAY_MS * (attempt + 1))
492
+ }
493
+ }
494
+
495
+ function isRetryableRemoveError(error: unknown): boolean {
496
+ let code = (error as NodeJS.ErrnoException | null)?.code
497
+ return code === 'EBUSY' || code === 'EPERM' || code === 'EMFILE' || code === 'ENFILE'
498
+ }
499
+
500
+ function openSqliteDatabase(config: SqliteDatabaseConfig): SqliteDatabaseClient {
501
+ let SqliteDatabaseConstructor = loadSqliteDatabaseConstructor()
502
+ let database = new SqliteDatabaseConstructor(config.filename)
503
+
504
+ // node:sqlite enables foreign keys by default while bun:sqlite follows SQLite's default
505
+ // (off), so set the pragma explicitly to make the option authoritative on both runtimes
506
+ database.exec('pragma foreign_keys = ' + (config.foreignKeys ? 'on' : 'off'))
507
+ // node:sqlite defaults to busy_timeout 0, which fails immediately with SQLITE_BUSY when
508
+ // another process holds a write lock
509
+ database.exec('pragma busy_timeout = ' + String(config.busyTimeout ?? 5000))
510
+
511
+ return database
512
+ }
513
+
514
+ function isSqliteDatabase(
515
+ input: SqliteDatabaseClient | SqliteDatabaseConfig,
516
+ ): input is SqliteDatabaseClient {
517
+ return (
518
+ 'prepare' in input &&
519
+ typeof input.prepare === 'function' &&
520
+ 'exec' in input &&
521
+ typeof input.exec === 'function'
522
+ )
287
523
  }
288
524
 
289
525
  function normalizeRows(rows: unknown[]): Record<string, unknown>[] {
@@ -1,5 +1,6 @@
1
1
  import { getTableName, getTablePrimaryKey } from '@remix-run/data-table'
2
- import type { DataManipulationOperation, Predicate, SqlStatement } from '@remix-run/data-table'
2
+ import type { Predicate, SqlStatement } from '@remix-run/data-table'
3
+ import type { DataManipulationOperation } from '@remix-run/data-table'
3
4
  import {
4
5
  collectColumns as collectColumnsHelper,
5
6
  normalizeJoinType as normalizeJoinTypeHelper,
@@ -43,8 +44,8 @@ export function compileSqliteOperation(operation: DataManipulationOperation): Sq
43
44
  compileGroupByClause(operation.groupBy) +
44
45
  compileHavingClause(operation.having, context) +
45
46
  compileOrderByClause(operation.orderBy) +
46
- compileLimitClause(operation.limit) +
47
- compileOffsetClause(operation.offset),
47
+ compileLimitClause(operation.limit, context) +
48
+ compileOffsetClause(operation.offset, context),
48
49
  values: context.values,
49
50
  }
50
51
  }
@@ -309,20 +310,20 @@ function compileOrderByClause(orderBy: { column: string; direction: 'asc' | 'des
309
310
  )
310
311
  }
311
312
 
312
- function compileLimitClause(limit: number | undefined): string {
313
+ function compileLimitClause(limit: number | undefined, context: CompileContext): string {
313
314
  if (limit === undefined) {
314
315
  return ''
315
316
  }
316
317
 
317
- return ' limit ' + String(limit)
318
+ return ' limit ' + pushValue(context, limit)
318
319
  }
319
320
 
320
- function compileOffsetClause(offset: number | undefined): string {
321
+ function compileOffsetClause(offset: number | undefined, context: CompileContext): string {
321
322
  if (offset === undefined) {
322
323
  return ''
323
324
  }
324
325
 
325
- return ' offset ' + String(offset)
326
+ return ' offset ' + pushValue(context, offset)
326
327
  }
327
328
 
328
329
  function compileReturningClause(returning: '*' | string[] | undefined): string {
@@ -1 +0,0 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/lib/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,uBAAuB,EACvB,sBAAsB,EACtB,yBAAyB,EACzB,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,uBAAuB,CAAA;AAK9B;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAAA;IACrC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAC3B;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAAA;IACpC,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAA;IAClC,GAAG,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,eAAe,CAAA;IAC1C,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,OAAO,EAAE,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;CACzB;AAED;;GAEG;AACH,qBAAa,qBAAsB,YAAW,eAAe;;IAC3D;;OAEG;IACH,OAAO,SAAW;IAElB;;OAEG;IACH,YAAY;;;;;;MAAA;IAMZ,YAAY,QAAQ,EAAE,cAAc,EASnC;IAED;;;;OAIG;IACH,UAAU,CAAC,SAAS,EAAE,yBAAyB,GAAG,YAAY,EAAE,CAG/D;IAED;;;;OAIG;IACG,OAAO,CAAC,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAqC/E;IAED;;;;;OAKG;IACG,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9E;IAED;;;;;OAKG;IACG,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,WAAW,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAahF;IAED;;;;;;OAMG;IACG,SAAS,CACb,KAAK,EAAE,QAAQ,EACf,MAAM,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,gBAAgB,GAC7B,OAAO,CAAC,OAAO,CAAC,CAYlB;IAED;;;;OAIG;IACG,gBAAgB,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAY9E;IAED;;;;OAIG;IACG,iBAAiB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAI9D;IAED;;;;OAIG;IACG,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhE;IAED;;;;;OAKG;IACG,eAAe,CAAC,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1E;IAED;;;;;OAKG;IACG,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG9E;IAED;;;;;OAKG;IACG,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3E;CAOF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,cAAc,GAAG,qBAAqB,CAE3F"}