@rdlabo/workers-hono-kit 0.11.1 → 0.11.2
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/dist/db/database.d.ts +27 -0
- package/dist/db/database.js +56 -6
- package/dist/db/index.d.ts +1 -1
- package/dist/db/retry.js +14 -2
- package/docs/api-db.md +2 -2
- package/docs/data-layer.md +10 -1
- package/package.json +1 -1
package/dist/db/database.d.ts
CHANGED
|
@@ -106,6 +106,9 @@ export interface DisposableDatabase<TDrizzle, TTx = TxOf<TDrizzle>> extends Data
|
|
|
106
106
|
* Use `query()` only for SELECTs that require read-after-write consistency or cannot use the
|
|
107
107
|
* configured replica. Fatal connection errors recreate the primary connection and repeat the
|
|
108
108
|
* SELECT at most once. Writes and transactions are never repeated for connection errors.
|
|
109
|
+
* Read-only transactions are serialized on a separately cached primary connection so their
|
|
110
|
+
* snapshot boundaries cannot mix with ordinary primary operations or with each other. Do not call
|
|
111
|
+
* `readTransaction()` recursively from its own callback.
|
|
109
112
|
*
|
|
110
113
|
* @typeParam TDrizzle - the consumer's Drizzle ORM type.
|
|
111
114
|
* @typeParam TTx - the transaction-handle type, inferred from `TDrizzle` by default.
|
|
@@ -120,6 +123,30 @@ export interface HyperdriveDatabase<TDrizzle, TTx = TxOf<TDrizzle>> extends Disp
|
|
|
120
123
|
* @returns the rows returned by the query, typed as `T`.
|
|
121
124
|
*/
|
|
122
125
|
query<T = unknown>(sql: string, params?: unknown[]): Promise<T>;
|
|
126
|
+
/**
|
|
127
|
+
* Run a repeatable-read, consistent snapshot on the primary database.
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* The callback is repeated at most once on a fresh connection after a fatal connection error.
|
|
131
|
+
* This is safe only because the transaction is declared read-only. Use {@link transaction} for
|
|
132
|
+
* writes; write transactions are never repeated after connection loss. Calls are serialized on
|
|
133
|
+
* one dedicated connection, so recursive `readTransaction()` calls are not supported.
|
|
134
|
+
*/
|
|
135
|
+
readTransaction<T>(fn: (reader: ReadTransaction<TTx>) => Promise<T>): Promise<T>;
|
|
136
|
+
}
|
|
137
|
+
/** Primary read-only transaction handles that share one consistent snapshot. */
|
|
138
|
+
export interface ReadTransaction<TTx> {
|
|
139
|
+
/**
|
|
140
|
+
* The consumer's Drizzle transaction handle.
|
|
141
|
+
*
|
|
142
|
+
* @remarks
|
|
143
|
+
* Drizzle's type does not distinguish read-only transactions. MySQL enforces read-only mode at
|
|
144
|
+
* runtime; applications may expose a narrower SELECT-only facade when they need compile-time
|
|
145
|
+
* enforcement.
|
|
146
|
+
*/
|
|
147
|
+
orm: TTx;
|
|
148
|
+
/** Run raw SQL on the same primary connection and snapshot. */
|
|
149
|
+
query: <T = unknown>(sql: string, params?: unknown[]) => Promise<T>;
|
|
123
150
|
}
|
|
124
151
|
/**
|
|
125
152
|
* Options for {@link createMysqlDatabase}.
|
package/dist/db/database.js
CHANGED
|
@@ -52,9 +52,13 @@ export function createHyperdriveDatabase(options) {
|
|
|
52
52
|
const { primaryHyperdrive, replicaHyperdrive, createOrm, connectionOptions } = options;
|
|
53
53
|
let primaryConn;
|
|
54
54
|
let replicaConn;
|
|
55
|
+
let readTransactionConn;
|
|
55
56
|
let orm;
|
|
57
|
+
let readTransactionOrm;
|
|
58
|
+
let readTransactionTail;
|
|
56
59
|
const primary = () => (primaryConn ??= connect(primaryHyperdrive, connectionOptions));
|
|
57
60
|
const replica = () => (replicaConn ??= connect(replicaHyperdrive, connectionOptions));
|
|
61
|
+
const readTransactionConnection = () => (readTransactionConn ??= connect(primaryHyperdrive, connectionOptions));
|
|
58
62
|
const ormFor = async () => (orm ??= createOrm(await primary()));
|
|
59
63
|
const readFrom = (connection, sql, params) => retryWhenDeadlock(async () => {
|
|
60
64
|
const [rows] = (await (await connection).query(sql, params));
|
|
@@ -72,6 +76,37 @@ export function createHyperdriveDatabase(options) {
|
|
|
72
76
|
reset(connection);
|
|
73
77
|
return readFrom(connectionFor(), sql, params);
|
|
74
78
|
};
|
|
79
|
+
const resetPrimary = (failedConnection) => {
|
|
80
|
+
if (primaryConn === failedConnection) {
|
|
81
|
+
primaryConn = undefined;
|
|
82
|
+
orm = undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const resetReadTransaction = (failedConnection) => {
|
|
86
|
+
if (readTransactionConn === failedConnection) {
|
|
87
|
+
readTransactionConn = undefined;
|
|
88
|
+
readTransactionOrm = undefined;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const withReadTransactionLock = (fn) => {
|
|
92
|
+
const result = readTransactionTail ? readTransactionTail.then(fn, fn) : fn();
|
|
93
|
+
readTransactionTail = result.then(() => undefined, () => undefined);
|
|
94
|
+
return result;
|
|
95
|
+
};
|
|
96
|
+
const runReadTransaction = async (connection, fn) => {
|
|
97
|
+
const conn = await connection;
|
|
98
|
+
const dz = (readTransactionOrm ??= createOrm(conn));
|
|
99
|
+
return retryWhenDeadlock(async () => {
|
|
100
|
+
await conn.query('SET TRANSACTION READ ONLY');
|
|
101
|
+
return dz.transaction((tx) => fn({
|
|
102
|
+
orm: tx,
|
|
103
|
+
query: async (sql, params = []) => {
|
|
104
|
+
const [rows] = (await conn.query(sql, params));
|
|
105
|
+
return rows;
|
|
106
|
+
},
|
|
107
|
+
}), { isolationLevel: 'repeatable read', withConsistentSnapshot: true });
|
|
108
|
+
});
|
|
109
|
+
};
|
|
75
110
|
return {
|
|
76
111
|
read(sql, params = []) {
|
|
77
112
|
return readWithRecovery(replica, (failedConnection) => {
|
|
@@ -81,12 +116,21 @@ export function createHyperdriveDatabase(options) {
|
|
|
81
116
|
}, sql, params);
|
|
82
117
|
},
|
|
83
118
|
query(sql, params = []) {
|
|
84
|
-
return readWithRecovery(primary,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
return readWithRecovery(primary, resetPrimary, sql, params);
|
|
120
|
+
},
|
|
121
|
+
async readTransaction(fn) {
|
|
122
|
+
return withReadTransactionLock(async () => {
|
|
123
|
+
const connection = readTransactionConnection();
|
|
124
|
+
const outcome = await runReadTransaction(connection, fn).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
125
|
+
if (outcome.ok) {
|
|
126
|
+
return outcome.value;
|
|
88
127
|
}
|
|
89
|
-
|
|
128
|
+
if (!isFatalConnectionError(outcome.error)) {
|
|
129
|
+
throw outcome.error;
|
|
130
|
+
}
|
|
131
|
+
resetReadTransaction(connection);
|
|
132
|
+
return runReadTransaction(readTransactionConnection(), fn);
|
|
133
|
+
});
|
|
90
134
|
},
|
|
91
135
|
async write(fn) {
|
|
92
136
|
const dz = await ormFor();
|
|
@@ -137,7 +181,13 @@ function isFatalConnectionError(error) {
|
|
|
137
181
|
while (typeof current === 'object' && current !== null && !seen.has(current)) {
|
|
138
182
|
seen.add(current);
|
|
139
183
|
const value = current;
|
|
140
|
-
if (value.fatal === true
|
|
184
|
+
if (value.fatal === true ||
|
|
185
|
+
value.code === 'PROTOCOL_CONNECTION_LOST' ||
|
|
186
|
+
value.code === 'ECONNRESET' ||
|
|
187
|
+
value.code === 'EPIPE' ||
|
|
188
|
+
(typeof value.message === 'string' &&
|
|
189
|
+
(value.message.includes('Connection lost:') ||
|
|
190
|
+
value.message.includes("Can't add new command when connection is in closed state")))) {
|
|
141
191
|
return true;
|
|
142
192
|
}
|
|
143
193
|
current = value.cause;
|
package/dist/db/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { retryWhenDeadlock } from './retry.js';
|
|
13
13
|
export { createMysqlDatabase, createHyperdriveDatabase, databaseFrom } from './database.js';
|
|
14
|
-
export type { Database, DisposableDatabase, HyperdriveDatabase, QueryRunner, TxOf, CreateMysqlDatabaseOptions, CreateHyperdriveDatabaseOptions, Connection, Pool, } from './database.js';
|
|
14
|
+
export type { Database, DisposableDatabase, HyperdriveDatabase, ReadTransaction, QueryRunner, TxOf, CreateMysqlDatabaseOptions, CreateHyperdriveDatabaseOptions, Connection, Pool, } from './database.js';
|
|
15
15
|
export { insertIdOf, affectedRowsOf, insertedIdsOf } from './write-result.js';
|
|
16
16
|
export type { DzWriteResult } from './write-result.js';
|
|
17
17
|
export { hyperdriveConnectionOptions, withMysqlConnections } from './connection.js';
|
package/dist/db/retry.js
CHANGED
|
@@ -32,8 +32,7 @@ export async function retryWhenDeadlock(fn, retries = 3, delay = 100) {
|
|
|
32
32
|
if (outcome.ok) {
|
|
33
33
|
return outcome.value;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
if (code === 'ER_LOCK_DEADLOCK' && attempt < retries - 1) {
|
|
35
|
+
if (isDeadlock(outcome.error) && attempt < retries - 1) {
|
|
37
36
|
await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
|
|
38
37
|
continue;
|
|
39
38
|
}
|
|
@@ -42,3 +41,16 @@ export async function retryWhenDeadlock(fn, retries = 3, delay = 100) {
|
|
|
42
41
|
// Unreachable: the loop returns on success and throws on the final failed attempt.
|
|
43
42
|
throw new Error('retryWhenDeadlock: exhausted retries');
|
|
44
43
|
}
|
|
44
|
+
function isDeadlock(error) {
|
|
45
|
+
let current = error;
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
while (typeof current === 'object' && current !== null && !seen.has(current)) {
|
|
48
|
+
seen.add(current);
|
|
49
|
+
const value = current;
|
|
50
|
+
if (value.code === 'ER_LOCK_DEADLOCK') {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
current = value.cause;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
package/docs/api-db.md
CHANGED
|
@@ -4,10 +4,10 @@ Requires the `drizzle-orm` and `mysql2` peers. Reads use raw SQL against the rep
|
|
|
4
4
|
|
|
5
5
|
| Export | Description |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `createHyperdriveDatabase(options)` | `HyperdriveDatabase` that lazily opens primary/replica connections from Hyperdrive bindings per request. `read()` targets the replica
|
|
7
|
+
| `createHyperdriveDatabase(options)` | `HyperdriveDatabase` that lazily opens primary/replica connections from Hyperdrive bindings per request. `read()` targets the replica, `query()` targets the primary, and `readTransaction()` pins raw and Drizzle reads to one primary consistent snapshot. Read transactions are serialized on a separately cached connection and must not be recursively nested. A SELECT or complete read-only transaction is repeated at most once on a fresh connection after a fatal mysql2 connection error. Writes and write transactions are not repeated. Workers cleans connections up at invocation end; the legacy `dispose()` is a no-op. |
|
|
8
8
|
| `createMysqlDatabase(options)` | Assemble a `Database` from an already-connected Drizzle ORM + replica `QueryRunner`. |
|
|
9
9
|
| `databaseFrom(orm, replica)` | Build a `Database` from an existing Drizzle instance + replica handle. |
|
|
10
|
-
| `Database` / `DisposableDatabase` / `HyperdriveDatabase` / `QueryRunner` / `TxOf` | The `read` / `query` / `write` / `transaction` API and its supporting types. |
|
|
10
|
+
| `Database` / `DisposableDatabase` / `HyperdriveDatabase` / `ReadTransaction` / `QueryRunner` / `TxOf` | The `read` / `query` / `readTransaction` / `write` / `transaction` API and its supporting types. |
|
|
11
11
|
| `hyperdriveConnectionOptions(hyperdrive, overrides?)` / `HyperdriveLike` / `ExecutionContextLike` | Build mysql2 `createConnection` options from a Hyperdrive binding (`disableEval`, `decimalNumbers`, `timezone '+09:00'` by default). `timezone` controls mysql2's JavaScript `Date` conversion; it does not change the MySQL session timezone. |
|
|
12
12
|
| `withMysqlConnections(...)` | Open primary/replica connections in parallel and run a function. Workers cleans them up at invocation end. |
|
|
13
13
|
| `retryWhenDeadlock(fn, retries?, delay?)` | Same deadlock-retry helper as the root export. |
|
package/docs/data-layer.md
CHANGED
|
@@ -2,7 +2,7 @@ Import database helpers from `@rdlabo/workers-hono-kit/db`. This entry point req
|
|
|
2
2
|
|
|
3
3
|
## Hyperdrive database
|
|
4
4
|
|
|
5
|
-
`createHyperdriveDatabase()` lazily opens primary and replica connections from Hyperdrive bindings. `read()` uses the replica query runner; `query()` provides an explicit raw primary SELECT for read-after-write consistency; writes and transactions use the primary Drizzle instance. After a fatal mysql2 connection error,
|
|
5
|
+
`createHyperdriveDatabase()` lazily opens primary and replica connections from Hyperdrive bindings. `read()` uses the replica query runner; `query()` provides an explicit raw primary SELECT for read-after-write consistency; writes and transactions use the primary Drizzle instance. `readTransaction()` runs Drizzle and raw reads against one primary repeatable-read snapshot. Read transactions are serialized on one separately cached connection so their boundaries cannot mix with each other or with ordinary primary operations. After a fatal mysql2 connection error, a single read or the complete read-only transaction opens a fresh connection and repeats at most once. Writes and write transactions are not repeated because their commit state may be ambiguous. Workers owns connection cleanup at invocation end.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { createHyperdriveDatabase } from '@rdlabo/workers-hono-kit/db';
|
|
@@ -18,8 +18,17 @@ const rows = await db.read<Item>('SELECT * FROM items WHERE id = ?', [id]);
|
|
|
18
18
|
const freshRows = await db.query<Item[]>('SELECT * FROM items WHERE id = ?', [id]);
|
|
19
19
|
await db.write((dz) => dz.insert(items).values(input));
|
|
20
20
|
await db.transaction((tx) => tx.insert(items).values(input));
|
|
21
|
+
|
|
22
|
+
const snapshot = await db.readTransaction(async ({ orm, query }) => ({
|
|
23
|
+
items: await orm.select().from(items),
|
|
24
|
+
count: await query<{ count: number }[]>('SELECT COUNT(*) count FROM items'),
|
|
25
|
+
}));
|
|
21
26
|
```
|
|
22
27
|
|
|
28
|
+
MySQL enforces `READ ONLY` for every transaction attempt. Drizzle does not provide a distinct read-only transaction type, so applications can wrap `orm` in a SELECT-only facade when they also want compile-time enforcement.
|
|
29
|
+
|
|
30
|
+
Do not call `readTransaction()` recursively from inside its callback. Calls share one serialized snapshot lane, so a nested call would wait for its own outer transaction to finish. Consumers that expose nested snapshot helpers should reuse the outer reader instead.
|
|
31
|
+
|
|
23
32
|
Use `hyperdriveConnectionOptions()` when constructing lower-level mysql2 connections. The default JavaScript date conversion timezone is `+09:00`; it does not change the MySQL session timezone.
|
|
24
33
|
|
|
25
34
|
## Writes and retries
|