@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
|
@@ -0,0 +1,127 @@
|
|
|
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 const DRIZZLE_ORM_OPTIONS = { mode: 'default', casing: 'snake_case' };
|
|
34
|
+
/**
|
|
35
|
+
* Build a `drizzle.config.ts` configuration object with the Workers MySQL defaults.
|
|
36
|
+
*
|
|
37
|
+
* Fixes `casing: 'snake_case'`, the `schema`/`out` paths, and `dbCredentials` (with env-based
|
|
38
|
+
* defaults), while leaving `tablesFilter` and `introspect` opt-in.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Returns a plain object rather than a typed drizzle-kit config so that `drizzle-kit` need not be a
|
|
42
|
+
* dependency of this package; the drizzle-kit CLI only reads the default export.
|
|
43
|
+
*
|
|
44
|
+
* @param options - configuration overrides; only `database` is required.
|
|
45
|
+
* @returns a plain configuration object suitable for `export default` in `drizzle.config.ts`.
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* // drizzle.config.ts
|
|
49
|
+
* import { workersDrizzleConfig } from '@rdlabo/workers-mysql/drizzle';
|
|
50
|
+
*
|
|
51
|
+
* export default workersDrizzleConfig({ database: 'app' });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function workersDrizzleConfig(options) {
|
|
55
|
+
const { database, host, port, user, password, schema = './src/db/schemes', out = './drizzle', tablesFilter, introspect, } = options;
|
|
56
|
+
// CI/production migrate absorbs the pattern of passing a whole AWS Secrets Manager RDS managed secret
|
|
57
|
+
// (keys host/port/dbname/username/password) via `DB_SECRET`. It is parsed with JSON.parse, so key-name
|
|
58
|
+
// differences (host ≠ DB_HOST) can be mapped and special characters in the password stay shell-safe.
|
|
59
|
+
// When `DB_SECRET` is set, it is treated as a complete secret and fully determines the connection
|
|
60
|
+
// (missing/invalid → throw). Only when it is unset do we fall back to the individual DB_* env vars
|
|
61
|
+
// and then the defaults (the local / db:generate path).
|
|
62
|
+
const secret = resolveDbSecret();
|
|
63
|
+
const dbCredentials = secret
|
|
64
|
+
? {
|
|
65
|
+
host: secret.host,
|
|
66
|
+
port: secret.port,
|
|
67
|
+
user: secret.username,
|
|
68
|
+
password: secret.password,
|
|
69
|
+
database: secret.dbname,
|
|
70
|
+
}
|
|
71
|
+
: {
|
|
72
|
+
host: host ?? process.env.DB_HOST ?? '127.0.0.1',
|
|
73
|
+
port: port ?? Number(process.env.DB_PORT ?? 3306),
|
|
74
|
+
user: user ?? process.env.DB_USER ?? 'root',
|
|
75
|
+
password: password ?? process.env.DB_PASSWORD ?? 'root',
|
|
76
|
+
database,
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
dialect: 'mysql',
|
|
80
|
+
schema,
|
|
81
|
+
out,
|
|
82
|
+
casing: 'snake_case',
|
|
83
|
+
...(tablesFilter ? { tablesFilter } : {}),
|
|
84
|
+
...(introspect ? { introspect } : {}),
|
|
85
|
+
dbCredentials,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @deprecated Use {@link workersDrizzleConfig}; retained for source compatibility with
|
|
90
|
+
* `@rdlabo/workers-hono-kit/db`.
|
|
91
|
+
*/
|
|
92
|
+
export const honoDrizzleConfig = workersDrizzleConfig;
|
|
93
|
+
/**
|
|
94
|
+
* Resolve an AWS RDS managed secret (a JSON string placed in `DB_SECRET`).
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* - `DB_SECRET` unset → `undefined` (the normal local / `db:generate` fallback).
|
|
98
|
+
* - When set, it must be complete connection info: **invalid JSON / a missing required key throws**
|
|
99
|
+
* (rather than silently falling back to localhost and causing an incident). A missing `port` alone
|
|
100
|
+
* defaults to 3306.
|
|
101
|
+
*
|
|
102
|
+
* Both `workersDrizzleConfig` (db:migrate) and the compatibility baseline command use this logic.
|
|
103
|
+
*
|
|
104
|
+
* @returns the resolved connection info, or `undefined` when `DB_SECRET` is unset.
|
|
105
|
+
* @throws Error when `DB_SECRET` is set but is not valid JSON or is missing a required key.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveDbSecret() {
|
|
108
|
+
const raw = process.env.DB_SECRET;
|
|
109
|
+
if (!raw) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error('DB_SECRET is set but is not valid JSON (expected an AWS RDS managed secret string).');
|
|
118
|
+
}
|
|
119
|
+
const { host, dbname, username, password } = parsed;
|
|
120
|
+
if (typeof host !== 'string' ||
|
|
121
|
+
typeof dbname !== 'string' ||
|
|
122
|
+
typeof username !== 'string' ||
|
|
123
|
+
typeof password !== 'string') {
|
|
124
|
+
throw new Error('DB_SECRET must contain string host, dbname, username, password (AWS RDS managed secret shape).');
|
|
125
|
+
}
|
|
126
|
+
return { host, dbname, username, password, port: parsed.port === undefined ? 3306 : Number(parsed.port) };
|
|
127
|
+
}
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run an async unit of work, retrying it on MySQL deadlock errors with exponential backoff.
|
|
3
|
+
*
|
|
4
|
+
* Retries are triggered only by the `ER_LOCK_DEADLOCK` error code. Each failed attempt waits
|
|
5
|
+
* `delay * attempt` milliseconds (linear growth of the base delay) before the next try, and any
|
|
6
|
+
* non-deadlock error is rethrown immediately without retrying.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* MySQL rolls back the entire transaction when it detects a deadlock, so re-running the same unit
|
|
10
|
+
* of work is safe. Pass a `fn` that represents one complete unit — a single statement or an entire
|
|
11
|
+
* transaction — because the whole `fn` is re-executed on each retry.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam T - resolved value produced by `fn`.
|
|
14
|
+
* @param fn - the unit of work to execute; it is invoked again from scratch on each retry.
|
|
15
|
+
* @param retries - maximum number of attempts (default `3`).
|
|
16
|
+
* @param delay - base backoff in milliseconds; attempt N waits `delay * N` (default `100`).
|
|
17
|
+
* @returns the value resolved by the first successful call to `fn`.
|
|
18
|
+
* @throws the last error thrown by `fn` once retries are exhausted, or any non-deadlock error on
|
|
19
|
+
* the first occurrence.
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* await retryWhenDeadlock(() => db.transaction(async (tx) => {
|
|
23
|
+
* await tx.insert(orders).values(order);
|
|
24
|
+
* await tx.update(stock).set({ qty: sql`qty - 1` }).where(eq(stock.id, order.itemId));
|
|
25
|
+
* }));
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function retryWhenDeadlock<T>(fn: () => Promise<T>, retries?: number, delay?: number): Promise<T>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run an async unit of work, retrying it on MySQL deadlock errors with exponential backoff.
|
|
3
|
+
*
|
|
4
|
+
* Retries are triggered only by the `ER_LOCK_DEADLOCK` error code. Each failed attempt waits
|
|
5
|
+
* `delay * attempt` milliseconds (linear growth of the base delay) before the next try, and any
|
|
6
|
+
* non-deadlock error is rethrown immediately without retrying.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* MySQL rolls back the entire transaction when it detects a deadlock, so re-running the same unit
|
|
10
|
+
* of work is safe. Pass a `fn` that represents one complete unit — a single statement or an entire
|
|
11
|
+
* transaction — because the whole `fn` is re-executed on each retry.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam T - resolved value produced by `fn`.
|
|
14
|
+
* @param fn - the unit of work to execute; it is invoked again from scratch on each retry.
|
|
15
|
+
* @param retries - maximum number of attempts (default `3`).
|
|
16
|
+
* @param delay - base backoff in milliseconds; attempt N waits `delay * N` (default `100`).
|
|
17
|
+
* @returns the value resolved by the first successful call to `fn`.
|
|
18
|
+
* @throws the last error thrown by `fn` once retries are exhausted, or any non-deadlock error on
|
|
19
|
+
* the first occurrence.
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* await retryWhenDeadlock(() => db.transaction(async (tx) => {
|
|
23
|
+
* await tx.insert(orders).values(order);
|
|
24
|
+
* await tx.update(stock).set({ qty: sql`qty - 1` }).where(eq(stock.id, order.itemId));
|
|
25
|
+
* }));
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export async function retryWhenDeadlock(fn, retries = 3, delay = 100) {
|
|
29
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
30
|
+
const invoke = async () => fn();
|
|
31
|
+
const outcome = await invoke().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
32
|
+
if (outcome.ok) {
|
|
33
|
+
return outcome.value;
|
|
34
|
+
}
|
|
35
|
+
if (isDeadlock(outcome.error) && attempt < retries - 1) {
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
throw outcome.error;
|
|
40
|
+
}
|
|
41
|
+
// Unreachable: the loop returns on success and throws on the final failed attempt.
|
|
42
|
+
throw new Error('retryWhenDeadlock: exhausted retries');
|
|
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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Pool } from 'mysql2/promise';
|
|
2
|
+
/**
|
|
3
|
+
* Connection parameters for the test MySQL server.
|
|
4
|
+
*
|
|
5
|
+
* @see {@link CreateTestDbOptions.connection} for how defaults are resolved.
|
|
6
|
+
*/
|
|
7
|
+
export interface TestDbConnection {
|
|
8
|
+
/** Server host. */
|
|
9
|
+
host: string;
|
|
10
|
+
/** Server port. */
|
|
11
|
+
port: number;
|
|
12
|
+
/** User name. */
|
|
13
|
+
user: string;
|
|
14
|
+
/** Password. */
|
|
15
|
+
password: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Options for {@link createTestDb}.
|
|
19
|
+
*/
|
|
20
|
+
export interface CreateTestDbOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Test database name (e.g. `'app_test'`). To isolate parallel runs per feature, resolve a per-run
|
|
23
|
+
* name on the caller side and pass it here.
|
|
24
|
+
*/
|
|
25
|
+
dbName: string;
|
|
26
|
+
/**
|
|
27
|
+
* Absolute path to the Drizzle migrations folder. Resolve it on the caller side, e.g.
|
|
28
|
+
* `join(here, '..', 'drizzle')`.
|
|
29
|
+
*/
|
|
30
|
+
migrationsFolder: string;
|
|
31
|
+
/**
|
|
32
|
+
* Connection overrides. Unspecified fields fall back to environment variables
|
|
33
|
+
* (`DB_HOST`/`DB_PORT`/`DB_USER`/`DB_PASSWORD`), then to `127.0.0.1`/`3306`/`root`/`root`.
|
|
34
|
+
*/
|
|
35
|
+
connection?: Partial<TestDbConnection>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Test database handle returned by {@link createTestDb}, bundling schema setup, pooling, and
|
|
39
|
+
* fixture helpers for a single test database.
|
|
40
|
+
*/
|
|
41
|
+
export interface TestDb {
|
|
42
|
+
/** The resolved test database name. */
|
|
43
|
+
readonly dbName: string;
|
|
44
|
+
/** The resolved connection parameters. */
|
|
45
|
+
readonly connection: TestDbConnection;
|
|
46
|
+
/**
|
|
47
|
+
* Drop and recreate the database, then apply the committed Drizzle migrations to build the schema.
|
|
48
|
+
*
|
|
49
|
+
* @returns A promise that resolves once migrations have been applied.
|
|
50
|
+
*/
|
|
51
|
+
resetSchema(): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Create a mysql2 pool connected to the test database.
|
|
54
|
+
*
|
|
55
|
+
* @remarks Call `pool.end()` (e.g. in `afterAll`) to release connections.
|
|
56
|
+
* @returns A connection pool for the test database.
|
|
57
|
+
*/
|
|
58
|
+
createTestPool(): Pool;
|
|
59
|
+
/**
|
|
60
|
+
* Truncate every base table in the database.
|
|
61
|
+
*
|
|
62
|
+
* @remarks Table names are discovered dynamically from `information_schema`; the
|
|
63
|
+
* `__drizzle_migrations` bookkeeping table is excluded. Foreign-key checks are disabled for the
|
|
64
|
+
* duration so truncation order does not matter.
|
|
65
|
+
* @param pool - Pool connected to the test database.
|
|
66
|
+
*/
|
|
67
|
+
truncateAll(pool: Pool): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Insert a single row, mapping column names to values — a generic fixture helper for specs.
|
|
70
|
+
*
|
|
71
|
+
* @param pool - Pool connected to the test database.
|
|
72
|
+
* @param table - Target table name.
|
|
73
|
+
* @param row - Column-name to value map. A no-op if empty.
|
|
74
|
+
*/
|
|
75
|
+
seed(pool: Pool, table: string, row: Record<string, unknown>): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Report whether the local MySQL server is reachable.
|
|
78
|
+
*
|
|
79
|
+
* @remarks Useful as a guard, e.g. `describe.skipIf(!(await mysqlReachable()))`.
|
|
80
|
+
* @returns `true` if a connection could be opened, otherwise `false`.
|
|
81
|
+
*/
|
|
82
|
+
mysqlReachable(): Promise<boolean>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Create a {@link TestDb} handle for a single test database.
|
|
86
|
+
*
|
|
87
|
+
* @remarks
|
|
88
|
+
* The test schema is built from the committed Drizzle migrations as the single source of truth
|
|
89
|
+
* (the `db:generate` output under `./drizzle`), rather than a hand-written `schema.sql`. This helper
|
|
90
|
+
* is Node-only test infrastructure (run under Vitest) and is unrelated to runtime behavior.
|
|
91
|
+
*
|
|
92
|
+
* @param options - Database name, migrations folder, and optional connection overrides. See
|
|
93
|
+
* {@link CreateTestDbOptions}.
|
|
94
|
+
* @returns A handle exposing schema setup, pooling, truncation, seeding, and a reachability probe.
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* const testDb = createTestDb({ dbName: 'app_test', migrationsFolder: join(here, '..', 'drizzle') });
|
|
98
|
+
* beforeAll(async () => {
|
|
99
|
+
* await testDb.resetSchema();
|
|
100
|
+
* });
|
|
101
|
+
* const pool = testDb.createTestPool();
|
|
102
|
+
* beforeEach(() => testDb.truncateAll(pool));
|
|
103
|
+
* afterAll(() => pool.end());
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export declare function createTestDb(options: CreateTestDbOptions): TestDb;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/mysql2';
|
|
2
|
+
import { migrate } from 'drizzle-orm/mysql2/migrator';
|
|
3
|
+
import { createConnection, createPool } from 'mysql2/promise';
|
|
4
|
+
function resolveConnection(override) {
|
|
5
|
+
const env = globalThis.process?.env ?? {};
|
|
6
|
+
return {
|
|
7
|
+
host: override?.host ?? env.DB_HOST ?? '127.0.0.1',
|
|
8
|
+
port: override?.port ?? Number(env.DB_PORT ?? '3306'),
|
|
9
|
+
user: override?.user ?? env.DB_USER ?? 'root',
|
|
10
|
+
password: override?.password ?? env.DB_PASSWORD ?? 'root',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Create a {@link TestDb} handle for a single test database.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* The test schema is built from the committed Drizzle migrations as the single source of truth
|
|
18
|
+
* (the `db:generate` output under `./drizzle`), rather than a hand-written `schema.sql`. This helper
|
|
19
|
+
* is Node-only test infrastructure (run under Vitest) and is unrelated to runtime behavior.
|
|
20
|
+
*
|
|
21
|
+
* @param options - Database name, migrations folder, and optional connection overrides. See
|
|
22
|
+
* {@link CreateTestDbOptions}.
|
|
23
|
+
* @returns A handle exposing schema setup, pooling, truncation, seeding, and a reachability probe.
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const testDb = createTestDb({ dbName: 'app_test', migrationsFolder: join(here, '..', 'drizzle') });
|
|
27
|
+
* beforeAll(async () => {
|
|
28
|
+
* await testDb.resetSchema();
|
|
29
|
+
* });
|
|
30
|
+
* const pool = testDb.createTestPool();
|
|
31
|
+
* beforeEach(() => testDb.truncateAll(pool));
|
|
32
|
+
* afterAll(() => pool.end());
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function createTestDb(options) {
|
|
36
|
+
const { dbName, migrationsFolder } = options;
|
|
37
|
+
const connection = resolveConnection(options.connection);
|
|
38
|
+
return {
|
|
39
|
+
dbName,
|
|
40
|
+
connection,
|
|
41
|
+
async resetSchema() {
|
|
42
|
+
const admin = await createConnection({ ...connection, multipleStatements: true });
|
|
43
|
+
await admin.query(`DROP DATABASE IF EXISTS \`${dbName}\`; CREATE DATABASE \`${dbName}\` DEFAULT CHARACTER SET utf8mb4;`);
|
|
44
|
+
await admin.changeUser({ database: dbName });
|
|
45
|
+
await migrate(drizzle(admin), { migrationsFolder });
|
|
46
|
+
await admin.end();
|
|
47
|
+
},
|
|
48
|
+
createTestPool() {
|
|
49
|
+
// decimalNumbers / timezone mirror the runtime hyperdriveConnectionOptions so specs read
|
|
50
|
+
// DECIMAL columns as numbers and handle datetime in +09:00 (JST), matching production.
|
|
51
|
+
const pool = createPool({
|
|
52
|
+
...connection,
|
|
53
|
+
database: dbName,
|
|
54
|
+
connectionLimit: 5,
|
|
55
|
+
decimalNumbers: true,
|
|
56
|
+
timezone: '+09:00',
|
|
57
|
+
});
|
|
58
|
+
// Pin ONLY_FULL_GROUP_BY on every pooled connection so GROUP BY violations surface in specs
|
|
59
|
+
// regardless of the server's my.cnf (the policy is centralized here, not left to each server). CONCAT keeps
|
|
60
|
+
// the server's other sql_mode flags and is harmless if ONLY_FULL_GROUP_BY is already present.
|
|
61
|
+
// mysql2 queues this SET ahead of the consumer's first query on each new physical connection.
|
|
62
|
+
pool.on('connection', (conn) => {
|
|
63
|
+
void conn.query("SET SESSION sql_mode = CONCAT(@@SESSION.sql_mode, ',ONLY_FULL_GROUP_BY')");
|
|
64
|
+
});
|
|
65
|
+
return pool;
|
|
66
|
+
},
|
|
67
|
+
async truncateAll(pool) {
|
|
68
|
+
const [rows] = await pool.query("SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ? AND table_type='BASE TABLE' AND table_name <> '__drizzle_migrations'", [dbName]);
|
|
69
|
+
const tables = rows.map((r) => r.t);
|
|
70
|
+
await pool.query('SET FOREIGN_KEY_CHECKS=0');
|
|
71
|
+
for (const t of tables) {
|
|
72
|
+
await pool.query(`TRUNCATE TABLE \`${t}\``);
|
|
73
|
+
}
|
|
74
|
+
await pool.query('SET FOREIGN_KEY_CHECKS=1');
|
|
75
|
+
},
|
|
76
|
+
async seed(pool, table, row) {
|
|
77
|
+
const cols = Object.keys(row);
|
|
78
|
+
if (cols.length === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const placeholders = cols.map(() => '?').join(', ');
|
|
82
|
+
const columnList = cols.map((c) => `\`${c}\``).join(', ');
|
|
83
|
+
await pool.query(`INSERT INTO \`${table}\` (${columnList}) VALUES (${placeholders})`, Object.values(row));
|
|
84
|
+
},
|
|
85
|
+
async mysqlReachable() {
|
|
86
|
+
const connect = async () => createConnection({ ...connection });
|
|
87
|
+
const c = await connect().catch(() => undefined);
|
|
88
|
+
if (!c) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const close = async () => c.end();
|
|
92
|
+
return close().then(() => true, () => false);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Pool } from 'mysql2/promise';
|
|
2
|
+
import type { DisposableDatabase } from '../database.js';
|
|
3
|
+
/** Options for {@link createPoolDatabase}. */
|
|
4
|
+
export interface CreatePoolDatabaseOptions<TDrizzle> {
|
|
5
|
+
/** Test pool used as both primary and replica. */
|
|
6
|
+
pool: Pool;
|
|
7
|
+
/** Drizzle instance built by the consumer with its own `drizzle-orm`. */
|
|
8
|
+
orm: TDrizzle;
|
|
9
|
+
}
|
|
10
|
+
/** Create a `Database` backed by one pool used as both primary and replica. */
|
|
11
|
+
export declare function createPoolDatabase<TDrizzle>(options: CreatePoolDatabaseOptions<TDrizzle>): DisposableDatabase<TDrizzle>;
|
|
12
|
+
/** Create a no-op database stub that fails on unexpected writes. */
|
|
13
|
+
export declare function createNoopDatabase<TDrizzle = unknown>(): DisposableDatabase<TDrizzle>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { databaseFrom } from '../database.js';
|
|
2
|
+
/** Create a `Database` backed by one pool used as both primary and replica. */
|
|
3
|
+
export function createPoolDatabase(options) {
|
|
4
|
+
const { pool, orm } = options;
|
|
5
|
+
const base = databaseFrom(orm, pool);
|
|
6
|
+
return {
|
|
7
|
+
...base,
|
|
8
|
+
async dispose() {
|
|
9
|
+
await pool.end();
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Create a no-op database stub that fails on unexpected writes. */
|
|
14
|
+
export function createNoopDatabase() {
|
|
15
|
+
return {
|
|
16
|
+
read: async () => [],
|
|
17
|
+
write: () => {
|
|
18
|
+
throw new Error('noopDatabase.write accessed unexpectedly');
|
|
19
|
+
},
|
|
20
|
+
transaction: () => {
|
|
21
|
+
throw new Error('noopDatabase.transaction accessed unexpectedly');
|
|
22
|
+
},
|
|
23
|
+
dispose: async () => { },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createTestDb } from './db.js';
|
|
2
|
+
export type { TestDb, CreateTestDbOptions, TestDbConnection } from './db.js';
|
|
3
|
+
export { createPoolDatabase, createNoopDatabase } from './fakes.js';
|
|
4
|
+
export type { CreatePoolDatabaseOptions } from './fakes.js';
|
|
5
|
+
export type { Database, DisposableDatabase, QueryRunner, TxOf } from '../database.js';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape of a Drizzle (mysql2) write result, narrowed to the fields callers actually read.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* A mysql2 INSERT/UPDATE/DELETE result is the tuple `[ResultSetHeader, FieldPacket[]]`. Typing the
|
|
6
|
+
* result this way lets repositories extract the common values without exposing the raw query
|
|
7
|
+
* builder or the full `ResultSetHeader` to the rest of the codebase.
|
|
8
|
+
*/
|
|
9
|
+
export type DzWriteResult = readonly [{
|
|
10
|
+
insertId: number;
|
|
11
|
+
affectedRows: number;
|
|
12
|
+
}, ...unknown[]];
|
|
13
|
+
/**
|
|
14
|
+
* Extract the auto-increment `insertId` from a write result.
|
|
15
|
+
*
|
|
16
|
+
* @param result - the result of a Drizzle (mysql2) INSERT/UPDATE/DELETE.
|
|
17
|
+
* @returns the `insertId` reported by mysql2 (the id of the first inserted row).
|
|
18
|
+
*/
|
|
19
|
+
export declare function insertIdOf(result: DzWriteResult): number;
|
|
20
|
+
/**
|
|
21
|
+
* Extract the number of affected rows from a write result.
|
|
22
|
+
*
|
|
23
|
+
* @param result - the result of a Drizzle (mysql2) INSERT/UPDATE/DELETE.
|
|
24
|
+
* @returns the `affectedRows` count reported by mysql2.
|
|
25
|
+
*/
|
|
26
|
+
export declare function affectedRowsOf(result: DzWriteResult): number;
|
|
27
|
+
/**
|
|
28
|
+
* Reconstruct the auto-increment ids assigned by a bulk INSERT.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* mysql2 reports only the first `insertId` for a multi-row INSERT, so the remaining ids are derived
|
|
32
|
+
* by assuming a contiguous sequence (`base`, `base + 1`, …). This holds for tables with a standard
|
|
33
|
+
* `AUTO_INCREMENT` column and the default `innodb_autoinc_lock_mode`.
|
|
34
|
+
*
|
|
35
|
+
* @param result - the result of a bulk INSERT.
|
|
36
|
+
* @param count - the number of rows that were inserted.
|
|
37
|
+
* @returns an array of the `count` auto-increment ids, starting at the reported `insertId`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function insertedIdsOf(result: DzWriteResult, count: number): number[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract the auto-increment `insertId` from a write result.
|
|
3
|
+
*
|
|
4
|
+
* @param result - the result of a Drizzle (mysql2) INSERT/UPDATE/DELETE.
|
|
5
|
+
* @returns the `insertId` reported by mysql2 (the id of the first inserted row).
|
|
6
|
+
*/
|
|
7
|
+
export function insertIdOf(result) {
|
|
8
|
+
return result[0].insertId;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Extract the number of affected rows from a write result.
|
|
12
|
+
*
|
|
13
|
+
* @param result - the result of a Drizzle (mysql2) INSERT/UPDATE/DELETE.
|
|
14
|
+
* @returns the `affectedRows` count reported by mysql2.
|
|
15
|
+
*/
|
|
16
|
+
export function affectedRowsOf(result) {
|
|
17
|
+
return result[0].affectedRows;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reconstruct the auto-increment ids assigned by a bulk INSERT.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* mysql2 reports only the first `insertId` for a multi-row INSERT, so the remaining ids are derived
|
|
24
|
+
* by assuming a contiguous sequence (`base`, `base + 1`, …). This holds for tables with a standard
|
|
25
|
+
* `AUTO_INCREMENT` column and the default `innodb_autoinc_lock_mode`.
|
|
26
|
+
*
|
|
27
|
+
* @param result - the result of a bulk INSERT.
|
|
28
|
+
* @param count - the number of rows that were inserted.
|
|
29
|
+
* @returns an array of the `count` auto-increment ids, starting at the reported `insertId`.
|
|
30
|
+
*/
|
|
31
|
+
export function insertedIdsOf(result, count) {
|
|
32
|
+
const base = result[0].insertId;
|
|
33
|
+
return Array.from({ length: count }, (_, i) => base + i);
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rdlabo/workers-mysql",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"registry": "https://registry.npmjs.org/"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"description": "MySQL and Hyperdrive utilities for Cloudflare Workers",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/rdlabo-dev/workers-hono-kit.git",
|
|
15
|
+
"directory": "packages/mysql"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"bin"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"workers-mysql-db-baseline": "./bin/db-baseline.mjs"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./drizzle": {
|
|
31
|
+
"types": "./dist/drizzle.d.ts",
|
|
32
|
+
"import": "./dist/drizzle.js",
|
|
33
|
+
"default": "./dist/drizzle.js"
|
|
34
|
+
},
|
|
35
|
+
"./migrations": {
|
|
36
|
+
"types": "./dist/migrations.d.ts",
|
|
37
|
+
"import": "./dist/migrations.js",
|
|
38
|
+
"default": "./dist/migrations.js"
|
|
39
|
+
},
|
|
40
|
+
"./baseline-cli": {
|
|
41
|
+
"types": "./dist/baseline-cli.d.ts",
|
|
42
|
+
"import": "./dist/baseline-cli.js",
|
|
43
|
+
"default": "./dist/baseline-cli.js"
|
|
44
|
+
},
|
|
45
|
+
"./testing": {
|
|
46
|
+
"types": "./dist/testing/index.d.ts",
|
|
47
|
+
"import": "./dist/testing/index.js",
|
|
48
|
+
"default": "./dist/testing/index.js"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.build.json",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:package": "node scripts/package-smoke.mjs",
|
|
56
|
+
"lint": "eslint \"src/**/*.ts\"",
|
|
57
|
+
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
|
58
|
+
"format": "prettier --write .",
|
|
59
|
+
"format:check": "prettier --check .",
|
|
60
|
+
"prepack": "npm run build"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=20.0.0"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"mysql2": "^3.24.3"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"@types/node": ">=20.19.43",
|
|
70
|
+
"drizzle-orm": "^0.45.2"
|
|
71
|
+
},
|
|
72
|
+
"peerDependenciesMeta": {
|
|
73
|
+
"drizzle-orm": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@hono/eslint-config": "^2.1.0",
|
|
79
|
+
"@rdlabo/workers-timezone": "^0.1.0",
|
|
80
|
+
"drizzle-orm": "^0.45.2",
|
|
81
|
+
"eslint": "^9.39.4",
|
|
82
|
+
"prettier": "^3.8.4",
|
|
83
|
+
"typescript": "~5.6.2",
|
|
84
|
+
"typescript-eslint": "^8.61.1",
|
|
85
|
+
"vitest": "^2.1.0"
|
|
86
|
+
}
|
|
87
|
+
}
|