@rdlabo/workers-hono-kit 0.11.0 → 0.11.1-beta.pr46.sha8ccc8da384f0

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.
@@ -6,8 +6,8 @@ import type { HyperdriveLike } from './connection.js';
6
6
  * @remarks
7
7
  * The two sides of the database are deliberately handled differently:
8
8
  *
9
- * - Reads go to the **replica** as raw SQL (`QueryRunner.query`) for transparency, returning plain
10
- * rows.
9
+ * - Reads go to the **replica** as raw SQL (`QueryRunner.query`) by default, returning plain rows.
10
+ * Hyperdrive-backed databases also expose `query()` for freshness-sensitive primary SELECTs.
11
11
  * - Writes and transactions go to the **primary** through the Drizzle ORM for type safety, but only
12
12
  * via `write(fn)` / `transaction(fn)` — the raw query builder is never exposed. The builder is
13
13
  * awaited inside those methods, which removes a foot-gun: a Drizzle builder is a lazy thenable, so
@@ -99,6 +99,55 @@ export interface DisposableDatabase<TDrizzle, TTx = TxOf<TDrizzle>> extends Data
99
99
  */
100
100
  dispose(): Promise<void>;
101
101
  }
102
+ /**
103
+ * A Hyperdrive-backed database with an explicit primary query path.
104
+ *
105
+ * @remarks
106
+ * Use `query()` only for SELECTs that require read-after-write consistency or cannot use the
107
+ * configured replica. Fatal connection errors recreate the primary connection and repeat the
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.
112
+ *
113
+ * @typeParam TDrizzle - the consumer's Drizzle ORM type.
114
+ * @typeParam TTx - the transaction-handle type, inferred from `TDrizzle` by default.
115
+ */
116
+ export interface HyperdriveDatabase<TDrizzle, TTx = TxOf<TDrizzle>> extends DisposableDatabase<TDrizzle, TTx> {
117
+ /**
118
+ * Run a raw SQL SELECT against the primary database.
119
+ *
120
+ * @typeParam T - the complete rows result type (for example, `User[]`).
121
+ * @param sql - the SQL text, with `?` placeholders for `params`.
122
+ * @param params - optional positional parameters.
123
+ * @returns the rows returned by the query, typed as `T`.
124
+ */
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>;
150
+ }
102
151
  /**
103
152
  * Options for {@link createMysqlDatabase}.
104
153
  *
@@ -156,19 +205,19 @@ export interface CreateHyperdriveDatabaseOptions<TDrizzle> {
156
205
  connectionOptions?: Record<string, unknown>;
157
206
  }
158
207
  /**
159
- * Create a {@link DisposableDatabase} that lazily opens its connections from Hyperdrive bindings.
208
+ * Create a {@link HyperdriveDatabase} that lazily opens its connections from Hyperdrive bindings.
160
209
  *
161
210
  * @remarks
162
211
  * Construct one per request. Connections and the ORM are created on first use and reused for the
163
- * lifetime of the instance; the read/write/transaction surface is identical to
164
- * {@link createMysqlDatabase}. Workers automatically cleans up connections at the end of the
165
- * invocation, so callers do not need to close them manually. Replica SELECTs are repeated once on
166
- * a fresh connection after a fatal mysql2 connection error. Writes and transactions are never
167
- * repeated for connection errors because their commit state can be ambiguous.
212
+ * lifetime of the instance. `read()` uses the replica, while `query()` provides an explicit
213
+ * primary SELECT path. Workers automatically cleans up connections at the end of the invocation,
214
+ * so callers do not need to close them manually. Either SELECT path is repeated once on a fresh
215
+ * connection after a fatal mysql2 connection error. Writes and transactions are never repeated
216
+ * for connection errors because their commit state can be ambiguous.
168
217
  *
169
218
  * @typeParam TDrizzle - the consumer's Drizzle ORM type.
170
219
  * @param options - the primary/replica Hyperdrive bindings, the ORM factory, and connection options.
171
- * @returns a {@link DisposableDatabase} whose compatibility `dispose()` method is a no-op; Workers
220
+ * @returns a {@link HyperdriveDatabase} whose compatibility `dispose()` method is a no-op; Workers
172
221
  * cleans up its invocation-scoped connections automatically.
173
222
  * @example
174
223
  * ```ts
@@ -180,7 +229,7 @@ export interface CreateHyperdriveDatabaseOptions<TDrizzle> {
180
229
  * await db.write((dz) => dz.insert(users).values(user));
181
230
  * ```
182
231
  */
183
- export declare function createHyperdriveDatabase<TDrizzle>(options: CreateHyperdriveDatabaseOptions<TDrizzle>): DisposableDatabase<TDrizzle>;
232
+ export declare function createHyperdriveDatabase<TDrizzle>(options: CreateHyperdriveDatabaseOptions<TDrizzle>): HyperdriveDatabase<TDrizzle>;
184
233
  /**
185
234
  * Internal helper that assembles a {@link Database} from an ORM and a replica connection.
186
235
  *
@@ -24,19 +24,19 @@ export function createMysqlDatabase(options) {
24
24
  return databaseFrom(options.orm, options.replica);
25
25
  }
26
26
  /**
27
- * Create a {@link DisposableDatabase} that lazily opens its connections from Hyperdrive bindings.
27
+ * Create a {@link HyperdriveDatabase} that lazily opens its connections from Hyperdrive bindings.
28
28
  *
29
29
  * @remarks
30
30
  * Construct one per request. Connections and the ORM are created on first use and reused for the
31
- * lifetime of the instance; the read/write/transaction surface is identical to
32
- * {@link createMysqlDatabase}. Workers automatically cleans up connections at the end of the
33
- * invocation, so callers do not need to close them manually. Replica SELECTs are repeated once on
34
- * a fresh connection after a fatal mysql2 connection error. Writes and transactions are never
35
- * repeated for connection errors because their commit state can be ambiguous.
31
+ * lifetime of the instance. `read()` uses the replica, while `query()` provides an explicit
32
+ * primary SELECT path. Workers automatically cleans up connections at the end of the invocation,
33
+ * so callers do not need to close them manually. Either SELECT path is repeated once on a fresh
34
+ * connection after a fatal mysql2 connection error. Writes and transactions are never repeated
35
+ * for connection errors because their commit state can be ambiguous.
36
36
  *
37
37
  * @typeParam TDrizzle - the consumer's Drizzle ORM type.
38
38
  * @param options - the primary/replica Hyperdrive bindings, the ORM factory, and connection options.
39
- * @returns a {@link DisposableDatabase} whose compatibility `dispose()` method is a no-op; Workers
39
+ * @returns a {@link HyperdriveDatabase} whose compatibility `dispose()` method is a no-op; Workers
40
40
  * cleans up its invocation-scoped connections automatically.
41
41
  * @example
42
42
  * ```ts
@@ -52,28 +52,85 @@ 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));
61
65
  return rows;
62
66
  });
67
+ const readWithRecovery = async (connectionFor, reset, sql, params) => {
68
+ const connection = connectionFor();
69
+ const outcome = await readFrom(connection, sql, params).then((rows) => ({ ok: true, rows }), (error) => ({ ok: false, error }));
70
+ if (outcome.ok) {
71
+ return outcome.rows;
72
+ }
73
+ if (!isFatalConnectionError(outcome.error)) {
74
+ throw outcome.error;
75
+ }
76
+ reset(connection);
77
+ return readFrom(connectionFor(), sql, params);
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
+ };
63
110
  return {
64
- async read(sql, params = []) {
65
- const connection = replica();
66
- const outcome = await readFrom(connection, sql, params).then((rows) => ({ ok: true, rows }), (error) => ({ ok: false, error }));
67
- if (outcome.ok) {
68
- return outcome.rows;
69
- }
70
- if (!isFatalConnectionError(outcome.error)) {
71
- throw outcome.error;
72
- }
73
- if (replicaConn === connection) {
74
- replicaConn = undefined;
75
- }
76
- return readFrom(replica(), sql, params);
111
+ read(sql, params = []) {
112
+ return readWithRecovery(replica, (failedConnection) => {
113
+ if (replicaConn === failedConnection) {
114
+ replicaConn = undefined;
115
+ }
116
+ }, sql, params);
117
+ },
118
+ query(sql, params = []) {
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;
127
+ }
128
+ if (!isFatalConnectionError(outcome.error)) {
129
+ throw outcome.error;
130
+ }
131
+ resetReadTransaction(connection);
132
+ return runReadTransaction(readTransactionConnection(), fn);
133
+ });
77
134
  },
78
135
  async write(fn) {
79
136
  const dz = await ormFor();
@@ -124,7 +181,13 @@ function isFatalConnectionError(error) {
124
181
  while (typeof current === 'object' && current !== null && !seen.has(current)) {
125
182
  seen.add(current);
126
183
  const value = current;
127
- 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")))) {
128
191
  return true;
129
192
  }
130
193
  current = value.cause;
@@ -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, 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
- const code = outcome.error.code;
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
@@ -1,13 +1,13 @@
1
1
  # API: `@rdlabo/workers-hono-kit/db`
2
2
 
3
- Requires the `drizzle-orm` and `mysql2` peers. Reads run against a replica via raw SQL; writes/transactions run against the primary through the Drizzle ORM with deadlock retry. The kit deliberately does not depend on the ORM's type identity — you pass the Drizzle instance in.
3
+ Requires the `drizzle-orm` and `mysql2` peers. Reads use raw SQL against the replica by default, with an explicit primary read path for freshness; writes/transactions run against the primary through the Drizzle ORM with deadlock retry. The kit deliberately does not depend on the ORM's type identity — you pass the Drizzle instance in.
4
4
 
5
5
  | Export | Description |
6
6
  | --- | --- |
7
- | `createHyperdriveDatabase(options)` | `DisposableDatabase` that lazily opens primary/replica connections from Hyperdrive bindings per request. A replica SELECT is repeated at most once on a fresh connection after a fatal mysql2 connection error; writes and transactions are not repeated. Workers cleans connections up at invocation end; the legacy `dispose()` is a no-op. |
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` / `QueryRunner` / `TxOf` | The `read` / `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. |
@@ -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. Reads use the replica query runner; writes and transactions use the primary Drizzle instance. After a fatal mysql2 connection error, a replica read opens a fresh connection and repeats the SELECT at most once. Writes and transactions are not repeated because their commit state may be ambiguous. Workers owns connection cleanup at invocation end.
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';
@@ -15,10 +15,20 @@ const db = createHyperdriveDatabase({
15
15
  });
16
16
 
17
17
  const rows = await db.read<Item>('SELECT * FROM items WHERE id = ?', [id]);
18
+ const freshRows = await db.query<Item[]>('SELECT * FROM items WHERE id = ?', [id]);
18
19
  await db.write((dz) => dz.insert(items).values(input));
19
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
+ }));
20
26
  ```
21
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
+
22
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.
23
33
 
24
34
  ## Writes and retries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.11.0",
3
+ "version": "0.11.1-beta.pr46.sha8ccc8da384f0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"