@rdlabo/workers-mysql 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/bin/db-baseline.mjs +4 -0
- package/dist/baseline-cli.d.ts +2 -0
- package/dist/baseline-cli.js +47 -0
- package/dist/columns.d.ts +38 -0
- package/dist/columns.js +38 -0
- package/dist/connection.d.ts +76 -0
- package/dist/connection.js +67 -0
- package/dist/database.d.ts +249 -0
- package/dist/database.js +196 -0
- package/dist/drizzle.d.ts +6 -0
- package/dist/drizzle.js +5 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/jst.d.ts +43 -0
- package/dist/jst.js +59 -0
- package/dist/migrate.d.ts +51 -0
- package/dist/migrate.js +109 -0
- package/dist/migrations.d.ts +5 -0
- package/dist/migrations.js +3 -0
- package/dist/orm-config.d.ts +134 -0
- package/dist/orm-config.js +127 -0
- package/dist/retry.d.ts +28 -0
- package/dist/retry.js +56 -0
- package/dist/testing/db.d.ts +106 -0
- package/dist/testing/db.js +95 -0
- package/dist/testing/fakes.d.ts +13 -0
- package/dist/testing/fakes.js +25 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +2 -0
- package/dist/write-result.d.ts +39 -0
- package/dist/write-result.js +34 -0
- package/package.json +87 -0
package/dist/database.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createConnection } from 'mysql2/promise';
|
|
2
|
+
import { hyperdriveConnectionOptions } from './connection.js';
|
|
3
|
+
import { retryWhenDeadlock } from './retry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Assemble a {@link Database} from an already-connected ORM and replica.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* The caller (typically the worker entry point) owns creating the connections and the ORM, and is
|
|
9
|
+
* responsible for closing the connections; this variant does not manage their lifecycle.
|
|
10
|
+
*
|
|
11
|
+
* @typeParam TDrizzle - the consumer's Drizzle ORM type.
|
|
12
|
+
* @param options - the write ORM and the read connection.
|
|
13
|
+
* @returns a {@link Database} backed by the supplied ORM and replica.
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const db = createMysqlDatabase({
|
|
17
|
+
* orm: drizzle(primary, { schema, ...DRIZZLE_ORM_OPTIONS }),
|
|
18
|
+
* replica,
|
|
19
|
+
* });
|
|
20
|
+
* const rows = await db.read<User>('SELECT * FROM users WHERE id = ?', [id]);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function createMysqlDatabase(options) {
|
|
24
|
+
return databaseFrom(options.orm, options.replica);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a {@link HyperdriveDatabase} that lazily opens its connections from Hyperdrive bindings.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* Construct one per request. Connections and the ORM are created on first use and reused for the
|
|
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
|
+
*
|
|
37
|
+
* @typeParam TDrizzle - the consumer's Drizzle ORM type.
|
|
38
|
+
* @param options - the primary/replica Hyperdrive bindings, the ORM factory, and connection options.
|
|
39
|
+
* @returns a {@link HyperdriveDatabase} whose compatibility `dispose()` method is a no-op; Workers
|
|
40
|
+
* cleans up its invocation-scoped connections automatically.
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const db = createHyperdriveDatabase({
|
|
44
|
+
* primaryHyperdrive: env.PRIMARY,
|
|
45
|
+
* replicaHyperdrive: env.REPLICA,
|
|
46
|
+
* createOrm: (primary) => drizzle(primary, { schema, ...DRIZZLE_ORM_OPTIONS }),
|
|
47
|
+
* });
|
|
48
|
+
* await db.write((dz) => dz.insert(users).values(user));
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function createHyperdriveDatabase(options) {
|
|
52
|
+
const { primaryHyperdrive, replicaHyperdrive, createOrm, connectionOptions } = options;
|
|
53
|
+
let primaryConn;
|
|
54
|
+
let replicaConn;
|
|
55
|
+
let readTransactionConn;
|
|
56
|
+
let orm;
|
|
57
|
+
let readTransactionOrm;
|
|
58
|
+
let readTransactionTail;
|
|
59
|
+
const primary = () => (primaryConn ??= connect(primaryHyperdrive, connectionOptions));
|
|
60
|
+
const replica = () => (replicaConn ??= connect(replicaHyperdrive, connectionOptions));
|
|
61
|
+
const readTransactionConnection = () => (readTransactionConn ??= connect(primaryHyperdrive, connectionOptions));
|
|
62
|
+
const ormFor = async () => (orm ??= createOrm(await primary()));
|
|
63
|
+
const readFrom = (connection, sql, params) => retryWhenDeadlock(async () => {
|
|
64
|
+
const [rows] = (await (await connection).query(sql, params));
|
|
65
|
+
return rows;
|
|
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
|
+
};
|
|
110
|
+
return {
|
|
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
|
+
});
|
|
134
|
+
},
|
|
135
|
+
async write(fn) {
|
|
136
|
+
const dz = await ormFor();
|
|
137
|
+
return retryWhenDeadlock(() => fn(dz));
|
|
138
|
+
},
|
|
139
|
+
async transaction(fn) {
|
|
140
|
+
const dz = (await ormFor());
|
|
141
|
+
return retryWhenDeadlock(() => dz.transaction(fn));
|
|
142
|
+
},
|
|
143
|
+
/** @deprecated Workers cleans up invocation-scoped connections automatically. */
|
|
144
|
+
async dispose() {
|
|
145
|
+
return;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Internal helper that assembles a {@link Database} from an ORM and a replica connection.
|
|
151
|
+
*
|
|
152
|
+
* @typeParam TDrizzle - the consumer's Drizzle ORM type.
|
|
153
|
+
* @param orm - the Drizzle ORM used for writes and transactions.
|
|
154
|
+
* @param replica - the connection used for reads.
|
|
155
|
+
* @returns a {@link Database} wiring reads to `replica` and writes to `orm`, both with deadlock retry.
|
|
156
|
+
* @internal
|
|
157
|
+
*/
|
|
158
|
+
export function databaseFrom(orm, replica) {
|
|
159
|
+
const drizzleLike = orm;
|
|
160
|
+
return {
|
|
161
|
+
read(sql, params = []) {
|
|
162
|
+
return retryWhenDeadlock(async () => {
|
|
163
|
+
const [rows] = (await replica.query(sql, params));
|
|
164
|
+
return rows;
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
write(fn) {
|
|
168
|
+
return retryWhenDeadlock(() => fn(orm));
|
|
169
|
+
},
|
|
170
|
+
transaction(fn) {
|
|
171
|
+
return retryWhenDeadlock(() => drizzleLike.transaction(fn));
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function connect(hyperdrive, extra) {
|
|
176
|
+
return createConnection(hyperdriveConnectionOptions(hyperdrive, extra));
|
|
177
|
+
}
|
|
178
|
+
function isFatalConnectionError(error) {
|
|
179
|
+
let current = error;
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
while (typeof current === 'object' && current !== null && !seen.has(current)) {
|
|
182
|
+
seen.add(current);
|
|
183
|
+
const value = current;
|
|
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")))) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
current = value.cause;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Drizzle adapters for `@rdlabo/workers-mysql`. Requires the optional `drizzle-orm` peer. */
|
|
2
|
+
export { jstTimestamp, jstDatetime, jstDate, jstOnUpdateNow } from './columns.js';
|
|
3
|
+
export { DRIZZLE_ORM_OPTIONS, workersDrizzleConfig, resolveDbSecret } from './orm-config.js';
|
|
4
|
+
export { honoDrizzleConfig } from './orm-config.js';
|
|
5
|
+
export type { WorkersDrizzleConfigOptions, ResolvedDbSecret } from './orm-config.js';
|
|
6
|
+
export type { HonoDrizzleConfigOptions } from './orm-config.js';
|
package/dist/drizzle.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Drizzle adapters for `@rdlabo/workers-mysql`. Requires the optional `drizzle-orm` peer. */
|
|
2
|
+
export { jstTimestamp, jstDatetime, jstDate, jstOnUpdateNow } from './columns.js';
|
|
3
|
+
export { DRIZZLE_ORM_OPTIONS, workersDrizzleConfig, resolveDbSecret } from './orm-config.js';
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- public compatibility alias
|
|
5
|
+
export { honoDrizzleConfig } from './orm-config.js';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL, Hyperdrive, and Drizzle infrastructure for Cloudflare Workers.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
export { retryWhenDeadlock } from './retry.js';
|
|
7
|
+
export { createMysqlDatabase, createHyperdriveDatabase, databaseFrom } from './database.js';
|
|
8
|
+
export type { Database, DisposableDatabase, HyperdriveDatabase, ReadTransaction, QueryRunner, TxOf, CreateMysqlDatabaseOptions, CreateHyperdriveDatabaseOptions, Connection, Pool, } from './database.js';
|
|
9
|
+
export { insertIdOf, affectedRowsOf, insertedIdsOf } from './write-result.js';
|
|
10
|
+
export type { DzWriteResult } from './write-result.js';
|
|
11
|
+
export { hyperdriveConnectionOptions, withMysqlConnections } from './connection.js';
|
|
12
|
+
export type { HyperdriveLike, ExecutionContextLike } from './connection.js';
|
|
13
|
+
export { MYSQL_TIMEZONE, toJstDate, jstTimestampParams, jstDatetimeParams, jstDateParams } from './jst.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL, Hyperdrive, and Drizzle infrastructure for Cloudflare Workers.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
export { retryWhenDeadlock } from './retry.js';
|
|
7
|
+
export { createMysqlDatabase, createHyperdriveDatabase, databaseFrom } from './database.js';
|
|
8
|
+
export { insertIdOf, affectedRowsOf, insertedIdsOf } from './write-result.js';
|
|
9
|
+
export { hyperdriveConnectionOptions, withMysqlConnections } from './connection.js';
|
|
10
|
+
export { MYSQL_TIMEZONE, toJstDate, jstTimestampParams, jstDatetimeParams, jstDateParams } from './jst.js';
|
package/dist/jst.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JST wire conversion and DATE-column normalization for MySQL / Drizzle.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* This module owns the MySQL fixed `+09:00` contract independently of any business-time
|
|
6
|
+
* package, plus the DATE column's `toDriver` and the column `customType` params.
|
|
7
|
+
*/
|
|
8
|
+
/** Default mysql2 connection `timezone` (for the existing JST DB deployment). */
|
|
9
|
+
export declare const MYSQL_TIMEZONE = "+09:00";
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a client input to `YYYY-MM-DD` (a JST business calendar date) for a MySQL `DATE` column.
|
|
12
|
+
* Accepts ISO 8601 / `YYYY-MM-DD` / empty strings. A `YYYY-MM-DD` value is passed through without
|
|
13
|
+
* constructing a `Date`.
|
|
14
|
+
*
|
|
15
|
+
* @param value - the string or nullish input to normalize.
|
|
16
|
+
* @returns the business date as `YYYY-MM-DD`, or `null` when the input cannot be resolved.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toJstDate(value: string | null | undefined): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Build the params for a `customType` backing a MySQL `timestamp` column with `Date` pass-through.
|
|
21
|
+
*
|
|
22
|
+
* @param fsp - optional fractional-seconds precision; when provided, emits `timestamp(fsp)`.
|
|
23
|
+
*/
|
|
24
|
+
export declare const jstTimestampParams: (fsp?: number) => {
|
|
25
|
+
dataType: () => string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Build the params for a `customType` backing a MySQL `datetime` column with `Date` pass-through.
|
|
29
|
+
*
|
|
30
|
+
* @param fsp - optional fractional-seconds precision; when provided, emits `datetime(fsp)`.
|
|
31
|
+
*/
|
|
32
|
+
export declare const jstDatetimeParams: (fsp?: number) => {
|
|
33
|
+
dataType: () => string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Build the params for a `customType` backing a MySQL `date` column with JST normalization.
|
|
37
|
+
*
|
|
38
|
+
* @returns params with `toDriver` running {@link toJstDate}.
|
|
39
|
+
*/
|
|
40
|
+
export declare const jstDateParams: () => {
|
|
41
|
+
dataType: () => string;
|
|
42
|
+
toDriver: (value: string | null) => string | null;
|
|
43
|
+
};
|
package/dist/jst.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JST wire conversion and DATE-column normalization for MySQL / Drizzle.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* This module owns the MySQL fixed `+09:00` contract independently of any business-time
|
|
6
|
+
* package, plus the DATE column's `toDriver` and the column `customType` params.
|
|
7
|
+
*/
|
|
8
|
+
/** Default mysql2 connection `timezone` (for the existing JST DB deployment). */
|
|
9
|
+
export const MYSQL_TIMEZONE = '+09:00';
|
|
10
|
+
const JST_OFFSET_MILLISECONDS = 9 * 60 * 60 * 1_000;
|
|
11
|
+
const pad2 = (value) => String(value).padStart(2, '0');
|
|
12
|
+
function fixedJstDate(instant) {
|
|
13
|
+
const wallClock = new Date(instant.getTime() + JST_OFFSET_MILLISECONDS);
|
|
14
|
+
return `${String(wallClock.getUTCFullYear()).padStart(4, '0')}-${pad2(wallClock.getUTCMonth() + 1)}-${pad2(wallClock.getUTCDate())}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Normalize a client input to `YYYY-MM-DD` (a JST business calendar date) for a MySQL `DATE` column.
|
|
18
|
+
* Accepts ISO 8601 / `YYYY-MM-DD` / empty strings. A `YYYY-MM-DD` value is passed through without
|
|
19
|
+
* constructing a `Date`.
|
|
20
|
+
*
|
|
21
|
+
* @param value - the string or nullish input to normalize.
|
|
22
|
+
* @returns the business date as `YYYY-MM-DD`, or `null` when the input cannot be resolved.
|
|
23
|
+
*/
|
|
24
|
+
export function toJstDate(value) {
|
|
25
|
+
const trimmed = value?.trim();
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
const instant = new Date(trimmed);
|
|
33
|
+
return Number.isNaN(instant.getTime()) ? null : fixedJstDate(instant);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the params for a `customType` backing a MySQL `timestamp` column with `Date` pass-through.
|
|
37
|
+
*
|
|
38
|
+
* @param fsp - optional fractional-seconds precision; when provided, emits `timestamp(fsp)`.
|
|
39
|
+
*/
|
|
40
|
+
export const jstTimestampParams = (fsp) => ({
|
|
41
|
+
dataType: () => (fsp != null ? `timestamp(${fsp})` : 'timestamp'),
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Build the params for a `customType` backing a MySQL `datetime` column with `Date` pass-through.
|
|
45
|
+
*
|
|
46
|
+
* @param fsp - optional fractional-seconds precision; when provided, emits `datetime(fsp)`.
|
|
47
|
+
*/
|
|
48
|
+
export const jstDatetimeParams = (fsp) => ({
|
|
49
|
+
dataType: () => (fsp != null ? `datetime(${fsp})` : 'datetime'),
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Build the params for a `customType` backing a MySQL `date` column with JST normalization.
|
|
53
|
+
*
|
|
54
|
+
* @returns params with `toDriver` running {@link toJstDate}.
|
|
55
|
+
*/
|
|
56
|
+
export const jstDateParams = () => ({
|
|
57
|
+
dataType: () => 'date',
|
|
58
|
+
toDriver: (value) => toJstDate(value),
|
|
59
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { QueryRunner } from './database.js';
|
|
2
|
+
/** Identifying info for the baseline (i.e. first) migration. */
|
|
3
|
+
export interface BaselineEntry {
|
|
4
|
+
/** The migration tag (e.g. `0000_melted_weapon_omega`). */
|
|
5
|
+
tag: string;
|
|
6
|
+
/** The `when` from `_journal.json` (= drizzle's `created_at` / `folderMillis`). */
|
|
7
|
+
when: number;
|
|
8
|
+
/** The sha256 of the raw `<tag>.sql` contents (the same algorithm as drizzle). */
|
|
9
|
+
hash: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read the baseline (first) entry from `migrationsFolder` (drizzle's `out`, e.g. `./drizzle`).
|
|
13
|
+
*
|
|
14
|
+
* @param migrationsFolder - the folder containing `meta/_journal.json` and `<tag>.sql`.
|
|
15
|
+
* @returns the baseline entry (tag/when/hash).
|
|
16
|
+
* @throws Error when the journal is missing, the entries are empty, or `<tag>.sql` is missing.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readBaselineEntry(migrationsFolder: string): BaselineEntry;
|
|
19
|
+
/** Options for {@link baselineMigrations}. */
|
|
20
|
+
export interface BaselineMigrationsOptions {
|
|
21
|
+
/** A QueryRunner for raw SQL (a mysql2 `Connection`/`Pool` is assignable). Must already be connected to the target DB. */
|
|
22
|
+
db: QueryRunner;
|
|
23
|
+
/** Drizzle's `out` folder (defaults to `./drizzle`). */
|
|
24
|
+
migrationsFolder?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The result of {@link baselineMigrations}. */
|
|
27
|
+
export type BaselineResult = {
|
|
28
|
+
status: 'inserted';
|
|
29
|
+
tag: string;
|
|
30
|
+
when: number;
|
|
31
|
+
hash: string;
|
|
32
|
+
} | {
|
|
33
|
+
status: 'already-baselined';
|
|
34
|
+
tag: string;
|
|
35
|
+
when: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Record the baseline (0000) as "applied" on an existing DB. Idempotent, with safety guards.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Guards:
|
|
42
|
+
* - If a baseline marker (`created_at = when`) already exists → **no-op** (`already-baselined`).
|
|
43
|
+
* - If there is no marker but `__drizzle_migrations` has other rows → **abort** (unexpected state).
|
|
44
|
+
* - If the target DB has no base tables (an empty DB) → **abort** (skipping 0000 on an empty DB would
|
|
45
|
+
* never create the tables; use `db:migrate` for a fresh DB).
|
|
46
|
+
*
|
|
47
|
+
* @param options - the connection and migrations folder; see {@link BaselineMigrationsOptions}.
|
|
48
|
+
* @returns whether a marker was inserted or the DB was already baselined.
|
|
49
|
+
* @throws Error when one of the guards above trips.
|
|
50
|
+
*/
|
|
51
|
+
export declare function baselineMigrations(options: BaselineMigrationsOptions): Promise<BaselineResult>;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brownfield baseline for Drizzle MySQL migrations.
|
|
3
|
+
*
|
|
4
|
+
* An existing (in-production) DB already has its schema, so running the committed baseline migration
|
|
5
|
+
* (`drizzle/0000_*.sql` = the CREATE TABLE statements introspected from the current schema) via
|
|
6
|
+
* `db:migrate` fails as every table collides. Instead, 0000 is **recorded as "applied" without being
|
|
7
|
+
* executed**.
|
|
8
|
+
*
|
|
9
|
+
* How "applied" is decided (drizzle-orm/mysql-core dialect.migrate): it determines the pending set from
|
|
10
|
+
* **only the maximum `created_at`** in `__drizzle_migrations(id, hash, created_at)`, running just the
|
|
11
|
+
* migrations where `max(created_at) < entry.when`. The hash is stored but not used for the decision. So
|
|
12
|
+
* inserting one row as the 0000 marker — `(hash, created_at = that entry's when)` — makes subsequent
|
|
13
|
+
* `db:migrate` runs apply only the later 0001+ (larger `when`) and skip 0000. A fresh / test DB has no
|
|
14
|
+
* marker, so the full chain runs (behavior unchanged).
|
|
15
|
+
*
|
|
16
|
+
* This function does not depend on `drizzle-orm` (it reads the journal/SQL itself and hashes with the
|
|
17
|
+
* same sha256 as drizzle). It runs raw SQL against a QueryRunner (a mysql2 `Connection`/`Pool` is
|
|
18
|
+
* structurally assignable).
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
/** The default migration-tracking table name used by drizzle. */
|
|
26
|
+
const MIGRATIONS_TABLE = '__drizzle_migrations';
|
|
27
|
+
/**
|
|
28
|
+
* Read the baseline (first) entry from `migrationsFolder` (drizzle's `out`, e.g. `./drizzle`).
|
|
29
|
+
*
|
|
30
|
+
* @param migrationsFolder - the folder containing `meta/_journal.json` and `<tag>.sql`.
|
|
31
|
+
* @returns the baseline entry (tag/when/hash).
|
|
32
|
+
* @throws Error when the journal is missing, the entries are empty, or `<tag>.sql` is missing.
|
|
33
|
+
*/
|
|
34
|
+
export function readBaselineEntry(migrationsFolder) {
|
|
35
|
+
const journalPath = join(migrationsFolder, 'meta', '_journal.json');
|
|
36
|
+
if (!existsSync(journalPath)) {
|
|
37
|
+
throw new Error(`Can't find meta/_journal.json under ${migrationsFolder}. Run \`drizzle-kit generate\` first.`);
|
|
38
|
+
}
|
|
39
|
+
const journal = JSON.parse(readFileSync(journalPath, 'utf8'));
|
|
40
|
+
const entries = journal.entries ?? [];
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
throw new Error(`No migration entries in ${journalPath}.`);
|
|
43
|
+
}
|
|
44
|
+
// The origin is always the first entry (0000). Later 0001+ are "new changes" that should run even on
|
|
45
|
+
// an existing DB.
|
|
46
|
+
const first = entries[0];
|
|
47
|
+
const sqlPath = join(migrationsFolder, `${first.tag}.sql`);
|
|
48
|
+
if (!existsSync(sqlPath)) {
|
|
49
|
+
throw new Error(`Can't find ${first.tag}.sql under ${migrationsFolder}.`);
|
|
50
|
+
}
|
|
51
|
+
const sql = readFileSync(sqlPath, 'utf8');
|
|
52
|
+
return { tag: first.tag, when: first.when, hash: createHash('sha256').update(sql).digest('hex') };
|
|
53
|
+
}
|
|
54
|
+
async function rowsOf(db, sql, params) {
|
|
55
|
+
const result = (await db.query(sql, params));
|
|
56
|
+
return result[0] ?? [];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Record the baseline (0000) as "applied" on an existing DB. Idempotent, with safety guards.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Guards:
|
|
63
|
+
* - If a baseline marker (`created_at = when`) already exists → **no-op** (`already-baselined`).
|
|
64
|
+
* - If there is no marker but `__drizzle_migrations` has other rows → **abort** (unexpected state).
|
|
65
|
+
* - If the target DB has no base tables (an empty DB) → **abort** (skipping 0000 on an empty DB would
|
|
66
|
+
* never create the tables; use `db:migrate` for a fresh DB).
|
|
67
|
+
*
|
|
68
|
+
* @param options - the connection and migrations folder; see {@link BaselineMigrationsOptions}.
|
|
69
|
+
* @returns whether a marker was inserted or the DB was already baselined.
|
|
70
|
+
* @throws Error when one of the guards above trips.
|
|
71
|
+
*/
|
|
72
|
+
export async function baselineMigrations(options) {
|
|
73
|
+
const { db, migrationsFolder = './drizzle' } = options;
|
|
74
|
+
const baseline = readBaselineEntry(migrationsFolder);
|
|
75
|
+
// Same DDL as the migrator (a no-op if it already exists).
|
|
76
|
+
await db.query(`create table if not exists \`${MIGRATIONS_TABLE}\` (
|
|
77
|
+
id serial primary key,
|
|
78
|
+
hash text not null,
|
|
79
|
+
created_at bigint
|
|
80
|
+
)`);
|
|
81
|
+
// If a baseline marker already exists, this is an idempotent no-op.
|
|
82
|
+
const existing = await rowsOf(db, `select id from \`${MIGRATIONS_TABLE}\` where created_at = ? limit 1`, [
|
|
83
|
+
baseline.when,
|
|
84
|
+
]);
|
|
85
|
+
if (existing.length > 0) {
|
|
86
|
+
return { status: 'already-baselined', tag: baseline.tag, when: baseline.when };
|
|
87
|
+
}
|
|
88
|
+
// No marker but rows exist = already in some other state. Abort to avoid misfiring.
|
|
89
|
+
const countRows = await rowsOf(db, `select count(*) as n from \`${MIGRATIONS_TABLE}\``);
|
|
90
|
+
const rowCount = Number(countRows[0]?.n ?? 0);
|
|
91
|
+
if (rowCount > 0) {
|
|
92
|
+
throw new Error(`${MIGRATIONS_TABLE} already has ${rowCount} row(s) but no baseline marker (created_at=${baseline.when}). ` +
|
|
93
|
+
`Migration state is unexpected — refusing to insert. Inspect \`${MIGRATIONS_TABLE}\` manually.`);
|
|
94
|
+
}
|
|
95
|
+
// Baselining an empty DB is dangerous (treating 0000 as skipped would never create the tables).
|
|
96
|
+
// Confirm this is a brownfield DB.
|
|
97
|
+
const tableRows = await rowsOf(db, `select count(*) as n from information_schema.tables
|
|
98
|
+
where table_schema = DATABASE() and table_type = 'BASE TABLE' and table_name <> ?`, [MIGRATIONS_TABLE]);
|
|
99
|
+
const baseTableCount = Number(tableRows[0]?.n ?? 0);
|
|
100
|
+
if (baseTableCount === 0) {
|
|
101
|
+
throw new Error(`Target DB has no base tables. baseline records 0000 as applied WITHOUT creating tables — this is only ` +
|
|
102
|
+
`for existing (brownfield) DBs. For a fresh/empty DB run \`drizzle-kit migrate\` instead.`);
|
|
103
|
+
}
|
|
104
|
+
await db.query(`insert into \`${MIGRATIONS_TABLE}\` (\`hash\`, \`created_at\`) values (?, ?)`, [
|
|
105
|
+
baseline.hash,
|
|
106
|
+
baseline.when,
|
|
107
|
+
]);
|
|
108
|
+
return { status: 'inserted', tag: baseline.tag, when: baseline.when, hash: baseline.hash };
|
|
109
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Node.js migration helpers for `@rdlabo/workers-mysql`. */
|
|
2
|
+
export { baselineMigrations, readBaselineEntry } from './migrate.js';
|
|
3
|
+
export type { BaselineMigrationsOptions, BaselineResult, BaselineEntry } from './migrate.js';
|
|
4
|
+
export { resolveDbSecret } from './orm-config.js';
|
|
5
|
+
export type { ResolvedDbSecret } from './orm-config.js';
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralizes Drizzle column-name casing so it is fixed (standard: `snake_case`) in both the
|
|
3
|
+
* config and the runtime ORM.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Casing is configured in two distinct places:
|
|
7
|
+
*
|
|
8
|
+
* 1. The top-level `casing` in `drizzle.config.ts` decides the column names that `db:generate`
|
|
9
|
+
* **creates** (see {@link workersDrizzleConfig}).
|
|
10
|
+
* 2. The `drizzle(conn, { …casing })` call decides the column names the **runtime write builder**
|
|
11
|
+
* resolves to (see {@link DRIZZLE_ORM_OPTIONS}).
|
|
12
|
+
*
|
|
13
|
+
* If these two disagree, a multi-word camelCase column without an explicit column name will be
|
|
14
|
+
* generated with one name but queried with another, producing a runtime `Unknown column` error —
|
|
15
|
+
* something neither the type-check nor the migration surface, so it is caught late. Sourcing both
|
|
16
|
+
* from here makes the mismatch structurally impossible. Casing is ignored for columns that declare
|
|
17
|
+
* an explicit name, so this is a pure safety net that does not change existing behavior.
|
|
18
|
+
*
|
|
19
|
+
* The runtime `drizzle()` call itself is made by the consuming app with its own `drizzle-orm`; the
|
|
20
|
+
* package only provides values, never the ORM instance, to avoid splitting `drizzle-orm` into two
|
|
21
|
+
* copies and breaking type identity.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Runtime ORM options shared by the consuming app's `drizzle()` call.
|
|
25
|
+
*
|
|
26
|
+
* Spread into the runtime ORM as `drizzle(conn, { schema, ...DRIZZLE_ORM_OPTIONS })` so the write
|
|
27
|
+
* builder resolves column names as `snake_case`, matching what `db:generate` creates.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* Fixes `mode: 'default'` and `casing: 'snake_case'`. See the module-level documentation for why
|
|
31
|
+
* the same casing must be used by both the config and the runtime ORM.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DRIZZLE_ORM_OPTIONS: {
|
|
34
|
+
readonly mode: "default";
|
|
35
|
+
readonly casing: "snake_case";
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Options for {@link workersDrizzleConfig}.
|
|
39
|
+
*/
|
|
40
|
+
export interface WorkersDrizzleConfigOptions {
|
|
41
|
+
/** drizzle-kit `dbCredentials.database` — the database name to connect to. */
|
|
42
|
+
database: string;
|
|
43
|
+
/** Database host; defaults to `process.env.DB_HOST` then `127.0.0.1`. */
|
|
44
|
+
host?: string;
|
|
45
|
+
/** Database port; defaults to `process.env.DB_PORT` then `3306`. */
|
|
46
|
+
port?: number;
|
|
47
|
+
/** Database user; defaults to `process.env.DB_USER` then `root`. */
|
|
48
|
+
user?: string;
|
|
49
|
+
/** Database password; defaults to `process.env.DB_PASSWORD` then `root`. */
|
|
50
|
+
password?: string;
|
|
51
|
+
/** Path to the schema directory; defaults to `'./src/db/schemes'`. */
|
|
52
|
+
schema?: string;
|
|
53
|
+
/** Output directory for generated migrations; defaults to `'./drizzle'`. */
|
|
54
|
+
out?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Optional table allow-list. Use this to restrict drizzle-kit to the schema's own tables when the
|
|
57
|
+
* database is shared with another application.
|
|
58
|
+
*/
|
|
59
|
+
tablesFilter?: string[];
|
|
60
|
+
/**
|
|
61
|
+
* Optional `db:introspect` (DB → JS) casing. This is an independent axis from the generation-side
|
|
62
|
+
* `casing: 'snake_case'` and only affects introspection output.
|
|
63
|
+
*/
|
|
64
|
+
introspect?: {
|
|
65
|
+
casing: 'camel' | 'preserve';
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build a `drizzle.config.ts` configuration object with the Workers MySQL defaults.
|
|
70
|
+
*
|
|
71
|
+
* Fixes `casing: 'snake_case'`, the `schema`/`out` paths, and `dbCredentials` (with env-based
|
|
72
|
+
* defaults), while leaving `tablesFilter` and `introspect` opt-in.
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* Returns a plain object rather than a typed drizzle-kit config so that `drizzle-kit` need not be a
|
|
76
|
+
* dependency of this package; the drizzle-kit CLI only reads the default export.
|
|
77
|
+
*
|
|
78
|
+
* @param options - configuration overrides; only `database` is required.
|
|
79
|
+
* @returns a plain configuration object suitable for `export default` in `drizzle.config.ts`.
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* // drizzle.config.ts
|
|
83
|
+
* import { workersDrizzleConfig } from '@rdlabo/workers-mysql/drizzle';
|
|
84
|
+
*
|
|
85
|
+
* export default workersDrizzleConfig({ database: 'app' });
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export declare function workersDrizzleConfig(options: WorkersDrizzleConfigOptions): {
|
|
89
|
+
dbCredentials: {
|
|
90
|
+
host: string;
|
|
91
|
+
port: number;
|
|
92
|
+
user: string;
|
|
93
|
+
password: string;
|
|
94
|
+
database: string;
|
|
95
|
+
};
|
|
96
|
+
introspect?: {
|
|
97
|
+
casing: "camel" | "preserve";
|
|
98
|
+
} | undefined;
|
|
99
|
+
tablesFilter?: string[] | undefined;
|
|
100
|
+
dialect: "mysql";
|
|
101
|
+
schema: string;
|
|
102
|
+
out: string;
|
|
103
|
+
casing: "snake_case";
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* @deprecated Use {@link workersDrizzleConfig}; retained for source compatibility with
|
|
107
|
+
* `@rdlabo/workers-hono-kit/db`.
|
|
108
|
+
*/
|
|
109
|
+
export declare const honoDrizzleConfig: typeof workersDrizzleConfig;
|
|
110
|
+
/** @deprecated Use {@link WorkersDrizzleConfigOptions}. */
|
|
111
|
+
export type HonoDrizzleConfigOptions = WorkersDrizzleConfigOptions;
|
|
112
|
+
/** The return value of {@link resolveDbSecret} (normalized connection info). */
|
|
113
|
+
export interface ResolvedDbSecret {
|
|
114
|
+
host: string;
|
|
115
|
+
port: number;
|
|
116
|
+
dbname: string;
|
|
117
|
+
username: string;
|
|
118
|
+
password: string;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolve an AWS RDS managed secret (a JSON string placed in `DB_SECRET`).
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* - `DB_SECRET` unset → `undefined` (the normal local / `db:generate` fallback).
|
|
125
|
+
* - When set, it must be complete connection info: **invalid JSON / a missing required key throws**
|
|
126
|
+
* (rather than silently falling back to localhost and causing an incident). A missing `port` alone
|
|
127
|
+
* defaults to 3306.
|
|
128
|
+
*
|
|
129
|
+
* Both `workersDrizzleConfig` (db:migrate) and the compatibility baseline command use this logic.
|
|
130
|
+
*
|
|
131
|
+
* @returns the resolved connection info, or `undefined` when `DB_SECRET` is unset.
|
|
132
|
+
* @throws Error when `DB_SECRET` is set but is not valid JSON or is missing a required key.
|
|
133
|
+
*/
|
|
134
|
+
export declare function resolveDbSecret(): ResolvedDbSecret | undefined;
|