@rdlabo/workers-hono-kit 0.3.4 → 0.3.6

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.
@@ -17,5 +17,7 @@ export type { DzWriteResult } from './write-result.js';
17
17
  export { hyperdriveConnectionOptions, withMysqlConnections } from './connection.js';
18
18
  export type { HyperdriveLike, ExecutionContextLike } from './connection.js';
19
19
  export { toJstDate, jstTimestampParams, jstDatetimeParams, jstDateParams } from './jst.js';
20
- export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig } from './orm-config.js';
21
- export type { HonoDrizzleConfigOptions } from './orm-config.js';
20
+ export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig, resolveDbSecret } from './orm-config.js';
21
+ export type { HonoDrizzleConfigOptions, ResolvedDbSecret } from './orm-config.js';
22
+ export { baselineMigrations, readBaselineEntry } from './migrate.js';
23
+ export type { BaselineMigrationsOptions, BaselineResult, BaselineEntry } from './migrate.js';
package/dist/db/index.js CHANGED
@@ -14,4 +14,5 @@ export { createMysqlDatabase, createHyperdriveDatabase, databaseFrom } from './d
14
14
  export { insertIdOf, affectedRowsOf, insertedIdsOf } from './write-result.js';
15
15
  export { hyperdriveConnectionOptions, withMysqlConnections } from './connection.js';
16
16
  export { toJstDate, jstTimestampParams, jstDatetimeParams, jstDateParams } from './jst.js';
17
- export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig } from './orm-config.js';
17
+ export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig, resolveDbSecret } from './orm-config.js';
18
+ export { baselineMigrations, readBaselineEntry } from './migrate.js';
@@ -0,0 +1,51 @@
1
+ import type { QueryRunner } from './database.js';
2
+ /** baseline(=最初の)マイグレーションの識別情報。 */
3
+ export interface BaselineEntry {
4
+ /** マイグレーション tag(例 `0000_melted_weapon_omega`)。 */
5
+ tag: string;
6
+ /** `_journal.json` の `when`(= drizzle の `created_at`/`folderMillis`)。 */
7
+ when: number;
8
+ /** `<tag>.sql` の生内容の sha256(drizzle と同一アルゴリズム)。 */
9
+ hash: string;
10
+ }
11
+ /**
12
+ * `migrationsFolder`(drizzle の `out`、例 `./drizzle`)から baseline(最初の)エントリを読む。
13
+ *
14
+ * @param migrationsFolder - `meta/_journal.json` と `<tag>.sql` を含むフォルダ。
15
+ * @returns baseline エントリ(tag/when/hash)。
16
+ * @throws journal が無い / エントリが空 / `<tag>.sql` が無い場合。
17
+ */
18
+ export declare function readBaselineEntry(migrationsFolder: string): BaselineEntry;
19
+ /** {@link baselineMigrations} のオプション。 */
20
+ export interface BaselineMigrationsOptions {
21
+ /** 生 SQL を実行する QueryRunner(mysql2 `Connection`/`Pool` が代入可能)。対象 DB に接続済みのこと。 */
22
+ db: QueryRunner;
23
+ /** drizzle の `out` フォルダ(既定 `./drizzle`)。 */
24
+ migrationsFolder?: string;
25
+ }
26
+ /** {@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
+ * 既存 DB へ baseline(0000)を「適用済み」として記録する。冪等・安全ガード付き。
39
+ *
40
+ * @remarks
41
+ * ガード:
42
+ * - 既に baseline marker(`created_at = when`)が在れば **no-op**(`already-baselined`)。
43
+ * - marker は無いが `__drizzle_migrations` に別の行が在る → **中断**(想定外の状態)。
44
+ * - 対象 DB に base table が 1 つも無い(空 DB)→ **中断**(空 DB は 0000 を skip すると
45
+ * テーブルが作られない。新規 DB には `db:migrate` を使う)。
46
+ *
47
+ * @param options - 接続と migrations フォルダ。{@link BaselineMigrationsOptions} 参照。
48
+ * @returns 挿入したか既に baseline 済みか。
49
+ * @throws 上記ガードに該当する場合。
50
+ */
51
+ export declare function baselineMigrations(options: BaselineMigrationsOptions): Promise<BaselineResult>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Brownfield baseline for Drizzle MySQL migrations.
3
+ *
4
+ * 既存(現行サービス)の DB は先にスキーマが存在するため、コミット済みの baseline マイグレーション
5
+ * (`drizzle/0000_*.sql` = 現行スキーマを introspect した CREATE TABLE 群)を `db:migrate` で流すと
6
+ * 全テーブルが衝突して失敗する。そこで 0000 を **実行せず「適用済み」として記録**する。
7
+ *
8
+ * 適用判定の根拠(drizzle-orm/mysql-core dialect.migrate): `__drizzle_migrations(id, hash,
9
+ * created_at)` の **`created_at` 最大値のみ**で未適用判定し、`max(created_at) < entry.when` の
10
+ * migration だけ実行する。hash は保存されるが判定には使われない。よって 0000 の marker として
11
+ * `(hash, created_at=当該 when)` を 1 行入れれば、以後 `db:migrate` は when がより大きい 0001+ だけを
12
+ * 適用し、0000 は skip する。新規/テスト DB は marker が無いのでフルチェーンが走る(挙動不変)。
13
+ *
14
+ * この関数は `drizzle-orm` に依存しない(journal/SQL を自前で読み、hash は drizzle と同じ sha256)。
15
+ * QueryRunner(mysql2 の `Connection`/`Pool` が構造的に代入可能)に対して生 SQL を実行する。
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ /** drizzle が使う既定のマイグレーション管理テーブル名。 */
23
+ const MIGRATIONS_TABLE = '__drizzle_migrations';
24
+ /**
25
+ * `migrationsFolder`(drizzle の `out`、例 `./drizzle`)から baseline(最初の)エントリを読む。
26
+ *
27
+ * @param migrationsFolder - `meta/_journal.json` と `<tag>.sql` を含むフォルダ。
28
+ * @returns baseline エントリ(tag/when/hash)。
29
+ * @throws journal が無い / エントリが空 / `<tag>.sql` が無い場合。
30
+ */
31
+ export function readBaselineEntry(migrationsFolder) {
32
+ const journalPath = join(migrationsFolder, 'meta', '_journal.json');
33
+ if (!existsSync(journalPath)) {
34
+ throw new Error(`Can't find meta/_journal.json under ${migrationsFolder}. Run \`drizzle-kit generate\` first.`);
35
+ }
36
+ const journal = JSON.parse(readFileSync(journalPath, 'utf8'));
37
+ const entries = journal.entries ?? [];
38
+ if (entries.length === 0) {
39
+ throw new Error(`No migration entries in ${journalPath}.`);
40
+ }
41
+ // 起点は必ず最初のエントリ(0000)。以降 0001+ は「新しい変更」なので既存 DB でも実行されるべき。
42
+ const first = entries[0];
43
+ const sqlPath = join(migrationsFolder, `${first.tag}.sql`);
44
+ if (!existsSync(sqlPath)) {
45
+ throw new Error(`Can't find ${first.tag}.sql under ${migrationsFolder}.`);
46
+ }
47
+ const sql = readFileSync(sqlPath, 'utf8');
48
+ return { tag: first.tag, when: first.when, hash: createHash('sha256').update(sql).digest('hex') };
49
+ }
50
+ async function rowsOf(db, sql, params) {
51
+ const result = (await db.query(sql, params));
52
+ return result[0] ?? [];
53
+ }
54
+ /**
55
+ * 既存 DB へ baseline(0000)を「適用済み」として記録する。冪等・安全ガード付き。
56
+ *
57
+ * @remarks
58
+ * ガード:
59
+ * - 既に baseline marker(`created_at = when`)が在れば **no-op**(`already-baselined`)。
60
+ * - marker は無いが `__drizzle_migrations` に別の行が在る → **中断**(想定外の状態)。
61
+ * - 対象 DB に base table が 1 つも無い(空 DB)→ **中断**(空 DB は 0000 を skip すると
62
+ * テーブルが作られない。新規 DB には `db:migrate` を使う)。
63
+ *
64
+ * @param options - 接続と migrations フォルダ。{@link BaselineMigrationsOptions} 参照。
65
+ * @returns 挿入したか既に baseline 済みか。
66
+ * @throws 上記ガードに該当する場合。
67
+ */
68
+ export async function baselineMigrations(options) {
69
+ const { db, migrationsFolder = './drizzle' } = options;
70
+ const baseline = readBaselineEntry(migrationsFolder);
71
+ // migrator と同一 DDL(存在すれば no-op)。
72
+ await db.query(`create table if not exists \`${MIGRATIONS_TABLE}\` (
73
+ id serial primary key,
74
+ hash text not null,
75
+ created_at bigint
76
+ )`);
77
+ // 既に baseline marker があれば冪等 no-op。
78
+ const existing = await rowsOf(db, `select id from \`${MIGRATIONS_TABLE}\` where created_at = ? limit 1`, [
79
+ baseline.when,
80
+ ]);
81
+ if (existing.length > 0) {
82
+ return { status: 'already-baselined', tag: baseline.tag, when: baseline.when };
83
+ }
84
+ // marker は無いが行が在る=既に別の状態。誤爆防止で中断。
85
+ const countRows = await rowsOf(db, `select count(*) as n from \`${MIGRATIONS_TABLE}\``);
86
+ const rowCount = Number(countRows[0]?.n ?? 0);
87
+ if (rowCount > 0) {
88
+ throw new Error(`${MIGRATIONS_TABLE} already has ${rowCount} row(s) but no baseline marker (created_at=${baseline.when}). ` +
89
+ `Migration state is unexpected — refusing to insert. Inspect \`${MIGRATIONS_TABLE}\` manually.`);
90
+ }
91
+ // 空 DB への baseline は危険(0000 を skip 扱いにするとテーブルが作られない)。brownfield 確認。
92
+ const tableRows = await rowsOf(db, `select count(*) as n from information_schema.tables
93
+ where table_schema = DATABASE() and table_type = 'BASE TABLE' and table_name <> ?`, [MIGRATIONS_TABLE]);
94
+ const baseTableCount = Number(tableRows[0]?.n ?? 0);
95
+ if (baseTableCount === 0) {
96
+ throw new Error(`Target DB has no base tables. baseline records 0000 as applied WITHOUT creating tables — this is only ` +
97
+ `for existing (brownfield) DBs. For a fresh/empty DB run \`drizzle-kit migrate\` instead.`);
98
+ }
99
+ await db.query(`insert into \`${MIGRATIONS_TABLE}\` (\`hash\`, \`created_at\`) values (?, ?)`, [
100
+ baseline.hash,
101
+ baseline.when,
102
+ ]);
103
+ return { status: 'inserted', tag: baseline.tag, when: baseline.when, hash: baseline.hash };
104
+ }
@@ -102,3 +102,22 @@ export declare function honoDrizzleConfig(options: HonoDrizzleConfigOptions): {
102
102
  out: string;
103
103
  casing: "snake_case";
104
104
  };
105
+ /** {@link resolveDbSecret} の戻り値(正規化済みの接続情報)。 */
106
+ export interface ResolvedDbSecret {
107
+ host: string;
108
+ port: number;
109
+ dbname: string;
110
+ username: string;
111
+ password: string;
112
+ }
113
+ /**
114
+ * AWS RDS マネージド secret(`DB_SECRET` に入れた JSON 文字列)を解決する。
115
+ *
116
+ * @remarks
117
+ * - `DB_SECRET` 未設定 → `undefined`(ローカル/`db:generate` の正常フォールバック)。
118
+ * - 設定されている場合は「完全な接続情報」であることを要求し、**不正 JSON / 必須キー欠損は throw**
119
+ * (静かに localhost へフォールバックして事故らせない)。`port` のみ欠損時は 3306 を補う。
120
+ *
121
+ * `honoDrizzleConfig`(db:migrate)と `workers-hono-kit-db-baseline` bin の双方が同じ解釈を使う。
122
+ */
123
+ export declare function resolveDbSecret(): ResolvedDbSecret | undefined;
@@ -53,6 +53,28 @@ export const DRIZZLE_ORM_OPTIONS = { mode: 'default', casing: 'snake_case' };
53
53
  */
54
54
  export function honoDrizzleConfig(options) {
55
55
  const { database, host, port, user, password, schema = './src/db/schemes', out = './drizzle', tablesFilter, introspect, } = options;
56
+ // CI/本番の migrate は AWS Secrets Manager の RDS マネージド secret(キー
57
+ // host/port/dbname/username/password)を `DB_SECRET` にまるごと渡す運用を吸収する。JSON.parse で
58
+ // 解釈するので secret のキー名(host≠DB_HOST)差を map でき、password の特殊文字もシェル安全。
59
+ // 未設定(ローカル/db:generate)は従来どおり個別 env → デフォルトにフォールバック。
60
+ // DB_SECRET が在れば「完全な secret」として全接続情報をそれで確定する(欠損/不正は throw)。
61
+ // 未設定時のみ従来の個別 env → デフォルトにフォールバックする。
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
+ };
56
78
  return {
57
79
  dialect: 'mysql',
58
80
  schema,
@@ -60,12 +82,37 @@ export function honoDrizzleConfig(options) {
60
82
  casing: 'snake_case',
61
83
  ...(tablesFilter ? { tablesFilter } : {}),
62
84
  ...(introspect ? { introspect } : {}),
63
- dbCredentials: {
64
- host: host ?? process.env.DB_HOST ?? '127.0.0.1',
65
- port: port ?? Number(process.env.DB_PORT ?? 3306),
66
- user: user ?? process.env.DB_USER ?? 'root',
67
- password: password ?? process.env.DB_PASSWORD ?? 'root',
68
- database,
69
- },
85
+ dbCredentials,
70
86
  };
71
87
  }
88
+ /**
89
+ * AWS RDS マネージド secret(`DB_SECRET` に入れた JSON 文字列)を解決する。
90
+ *
91
+ * @remarks
92
+ * - `DB_SECRET` 未設定 → `undefined`(ローカル/`db:generate` の正常フォールバック)。
93
+ * - 設定されている場合は「完全な接続情報」であることを要求し、**不正 JSON / 必須キー欠損は throw**
94
+ * (静かに localhost へフォールバックして事故らせない)。`port` のみ欠損時は 3306 を補う。
95
+ *
96
+ * `honoDrizzleConfig`(db:migrate)と `workers-hono-kit-db-baseline` bin の双方が同じ解釈を使う。
97
+ */
98
+ export function resolveDbSecret() {
99
+ const raw = process.env.DB_SECRET;
100
+ if (!raw) {
101
+ return undefined;
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(raw);
106
+ }
107
+ catch {
108
+ throw new Error('DB_SECRET is set but is not valid JSON (expected an AWS RDS managed secret string).');
109
+ }
110
+ const { host, dbname, username, password } = parsed;
111
+ if (typeof host !== 'string' ||
112
+ typeof dbname !== 'string' ||
113
+ typeof username !== 'string' ||
114
+ typeof password !== 'string') {
115
+ throw new Error('DB_SECRET must contain string host, dbname, username, password (AWS RDS managed secret shape).');
116
+ }
117
+ return { host, dbname, username, password, port: parsed.port === undefined ? 3306 : Number(parsed.port) };
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -41,7 +41,8 @@
41
41
  ],
42
42
  "bin": {
43
43
  "workers-hono-kit-sync-dev-aws": "./scripts/sync-dev-aws.mjs",
44
- "workers-hono-kit-check-subrequest-fanout": "./scripts/check-subrequest-fanout.mjs"
44
+ "workers-hono-kit-check-subrequest-fanout": "./scripts/check-subrequest-fanout.mjs",
45
+ "workers-hono-kit-db-baseline": "./scripts/db-baseline.mjs"
45
46
  },
46
47
  "exports": {
47
48
  ".": {
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // Record the Drizzle baseline (0000) as *already applied* on an existing (brownfield) MySQL DB,
3
+ // without executing its CREATE TABLE statements. One-time per environment.
4
+ //
5
+ // なぜ: 現行サービスの DB は先にスキーマが在るため、introspect 由来の baseline 0000 を `db:migrate`
6
+ // で流すと衝突する。代わりに `__drizzle_migrations` に marker を 1 行入れ、以後 `db:migrate` が when の
7
+ // 大きい 0001+ だけを適用するようにする(新規/テスト DB は marker 無しでフルチェーン=挙動不変)。
8
+ //
9
+ // 実行基盤: VPC 内(AWS CodeBuild 等)から RDS へ直 TCP。Hyperdrive は Workers 専用で使えない。
10
+ // creds は env(CodeBuild では Secrets Manager → env に注入):
11
+ // DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME
12
+ // migrations フォルダ: 既定 ./drizzle(--migrations <dir> または MIGRATIONS_DIR で上書き)。
13
+ //
14
+ // usage:
15
+ // npx workers-hono-kit-db-baseline [--migrations ./drizzle]
16
+ import { createConnection } from 'mysql2/promise';
17
+ import { baselineMigrations } from '../dist/db/migrate.js';
18
+ import { resolveDbSecret } from '../dist/db/index.js';
19
+
20
+ function arg(name) {
21
+ const i = process.argv.indexOf(`--${name}`);
22
+ return i >= 0 ? process.argv[i + 1] : undefined;
23
+ }
24
+
25
+ const migrationsFolder = arg('migrations') ?? process.env.MIGRATIONS_DIR ?? './drizzle';
26
+ // db:migrate(honoDrizzleConfig)と同じ DB_SECRET 解釈を共有する。CI/本番は AWS Secrets Manager の
27
+ // RDS マネージド secret を DB_SECRET に渡す運用(不正/欠損は resolveDbSecret が throw)。未設定時は
28
+ // 従来の個別 DB_* env にフォールバック。
29
+ const secret = resolveDbSecret();
30
+ const conn = secret
31
+ ? {
32
+ host: secret.host,
33
+ port: secret.port,
34
+ user: secret.username,
35
+ password: secret.password,
36
+ database: secret.dbname,
37
+ }
38
+ : {
39
+ host: process.env.DB_HOST ?? '127.0.0.1',
40
+ port: Number(process.env.DB_PORT ?? '3306'),
41
+ user: process.env.DB_USER ?? 'root',
42
+ password: process.env.DB_PASSWORD ?? 'root',
43
+ database: process.env.DB_NAME,
44
+ };
45
+
46
+ if (!conn.database) {
47
+ console.error('[db:baseline] DB_NAME (or DB_SECRET) is required.');
48
+ process.exit(1);
49
+ }
50
+
51
+ console.log(`[db:baseline] target = ${conn.user}@${conn.host}:${conn.port}/${conn.database} (migrations: ${migrationsFolder})`);
52
+
53
+ const db = await createConnection(conn);
54
+ try {
55
+ const res = await baselineMigrations({ db, migrationsFolder });
56
+ if (res.status === 'already-baselined') {
57
+ console.log(`[db:baseline] already baselined (${res.tag}, created_at=${res.when}). no-op.`);
58
+ } else {
59
+ console.log(
60
+ `[db:baseline] inserted baseline marker for ${res.tag} (created_at=${res.when}). ` +
61
+ `0000 is now recorded as applied; future migrations will run.`,
62
+ );
63
+ }
64
+ } catch (err) {
65
+ console.error('[db:baseline] failed:', err instanceof Error ? err.message : err);
66
+ process.exitCode = 1;
67
+ } finally {
68
+ await db.end();
69
+ }
package/src/db/index.ts CHANGED
@@ -32,5 +32,8 @@ export type { HyperdriveLike, ExecutionContextLike } from './connection.js';
32
32
 
33
33
  export { toJstDate, jstTimestampParams, jstDatetimeParams, jstDateParams } from './jst.js';
34
34
 
35
- export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig } from './orm-config.js';
36
- export type { HonoDrizzleConfigOptions } from './orm-config.js';
35
+ export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig, resolveDbSecret } from './orm-config.js';
36
+ export type { HonoDrizzleConfigOptions, ResolvedDbSecret } from './orm-config.js';
37
+
38
+ export { baselineMigrations, readBaselineEntry } from './migrate.js';
39
+ export type { BaselineMigrationsOptions, BaselineResult, BaselineEntry } from './migrate.js';
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Brownfield baseline for Drizzle MySQL migrations.
3
+ *
4
+ * 既存(現行サービス)の DB は先にスキーマが存在するため、コミット済みの baseline マイグレーション
5
+ * (`drizzle/0000_*.sql` = 現行スキーマを introspect した CREATE TABLE 群)を `db:migrate` で流すと
6
+ * 全テーブルが衝突して失敗する。そこで 0000 を **実行せず「適用済み」として記録**する。
7
+ *
8
+ * 適用判定の根拠(drizzle-orm/mysql-core dialect.migrate): `__drizzle_migrations(id, hash,
9
+ * created_at)` の **`created_at` 最大値のみ**で未適用判定し、`max(created_at) < entry.when` の
10
+ * migration だけ実行する。hash は保存されるが判定には使われない。よって 0000 の marker として
11
+ * `(hash, created_at=当該 when)` を 1 行入れれば、以後 `db:migrate` は when がより大きい 0001+ だけを
12
+ * 適用し、0000 は skip する。新規/テスト DB は marker が無いのでフルチェーンが走る(挙動不変)。
13
+ *
14
+ * この関数は `drizzle-orm` に依存しない(journal/SQL を自前で読み、hash は drizzle と同じ sha256)。
15
+ * QueryRunner(mysql2 の `Connection`/`Pool` が構造的に代入可能)に対して生 SQL を実行する。
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ import type { QueryRunner } from './database.js';
24
+
25
+ /** drizzle が使う既定のマイグレーション管理テーブル名。 */
26
+ const MIGRATIONS_TABLE = '__drizzle_migrations';
27
+
28
+ /** `meta/_journal.json` の 1 エントリ(必要なフィールドのみ)。 */
29
+ interface JournalEntry {
30
+ idx: number;
31
+ when: number;
32
+ tag: string;
33
+ }
34
+
35
+ /** baseline(=最初の)マイグレーションの識別情報。 */
36
+ export interface BaselineEntry {
37
+ /** マイグレーション tag(例 `0000_melted_weapon_omega`)。 */
38
+ tag: string;
39
+ /** `_journal.json` の `when`(= drizzle の `created_at`/`folderMillis`)。 */
40
+ when: number;
41
+ /** `<tag>.sql` の生内容の sha256(drizzle と同一アルゴリズム)。 */
42
+ hash: string;
43
+ }
44
+
45
+ /**
46
+ * `migrationsFolder`(drizzle の `out`、例 `./drizzle`)から baseline(最初の)エントリを読む。
47
+ *
48
+ * @param migrationsFolder - `meta/_journal.json` と `<tag>.sql` を含むフォルダ。
49
+ * @returns baseline エントリ(tag/when/hash)。
50
+ * @throws journal が無い / エントリが空 / `<tag>.sql` が無い場合。
51
+ */
52
+ export function readBaselineEntry(migrationsFolder: string): BaselineEntry {
53
+ const journalPath = join(migrationsFolder, 'meta', '_journal.json');
54
+ if (!existsSync(journalPath)) {
55
+ throw new Error(`Can't find meta/_journal.json under ${migrationsFolder}. Run \`drizzle-kit generate\` first.`);
56
+ }
57
+ const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries?: JournalEntry[] };
58
+ const entries = journal.entries ?? [];
59
+ if (entries.length === 0) {
60
+ throw new Error(`No migration entries in ${journalPath}.`);
61
+ }
62
+ // 起点は必ず最初のエントリ(0000)。以降 0001+ は「新しい変更」なので既存 DB でも実行されるべき。
63
+ const first = entries[0];
64
+ const sqlPath = join(migrationsFolder, `${first.tag}.sql`);
65
+ if (!existsSync(sqlPath)) {
66
+ throw new Error(`Can't find ${first.tag}.sql under ${migrationsFolder}.`);
67
+ }
68
+ const sql = readFileSync(sqlPath, 'utf8');
69
+ return { tag: first.tag, when: first.when, hash: createHash('sha256').update(sql).digest('hex') };
70
+ }
71
+
72
+ /** {@link baselineMigrations} のオプション。 */
73
+ export interface BaselineMigrationsOptions {
74
+ /** 生 SQL を実行する QueryRunner(mysql2 `Connection`/`Pool` が代入可能)。対象 DB に接続済みのこと。 */
75
+ db: QueryRunner;
76
+ /** drizzle の `out` フォルダ(既定 `./drizzle`)。 */
77
+ migrationsFolder?: string;
78
+ }
79
+
80
+ /** {@link baselineMigrations} の結果。 */
81
+ export type BaselineResult =
82
+ | { status: 'inserted'; tag: string; when: number; hash: string }
83
+ | { status: 'already-baselined'; tag: string; when: number };
84
+
85
+ async function rowsOf(db: QueryRunner, sql: string, params?: unknown[]): Promise<Record<string, unknown>[]> {
86
+ const result = (await db.query(sql, params)) as [Record<string, unknown>[] | undefined, unknown];
87
+ return result[0] ?? [];
88
+ }
89
+
90
+ /**
91
+ * 既存 DB へ baseline(0000)を「適用済み」として記録する。冪等・安全ガード付き。
92
+ *
93
+ * @remarks
94
+ * ガード:
95
+ * - 既に baseline marker(`created_at = when`)が在れば **no-op**(`already-baselined`)。
96
+ * - marker は無いが `__drizzle_migrations` に別の行が在る → **中断**(想定外の状態)。
97
+ * - 対象 DB に base table が 1 つも無い(空 DB)→ **中断**(空 DB は 0000 を skip すると
98
+ * テーブルが作られない。新規 DB には `db:migrate` を使う)。
99
+ *
100
+ * @param options - 接続と migrations フォルダ。{@link BaselineMigrationsOptions} 参照。
101
+ * @returns 挿入したか既に baseline 済みか。
102
+ * @throws 上記ガードに該当する場合。
103
+ */
104
+ export async function baselineMigrations(options: BaselineMigrationsOptions): Promise<BaselineResult> {
105
+ const { db, migrationsFolder = './drizzle' } = options;
106
+ const baseline = readBaselineEntry(migrationsFolder);
107
+
108
+ // migrator と同一 DDL(存在すれば no-op)。
109
+ await db.query(
110
+ `create table if not exists \`${MIGRATIONS_TABLE}\` (
111
+ id serial primary key,
112
+ hash text not null,
113
+ created_at bigint
114
+ )`,
115
+ );
116
+
117
+ // 既に baseline marker があれば冪等 no-op。
118
+ const existing = await rowsOf(db, `select id from \`${MIGRATIONS_TABLE}\` where created_at = ? limit 1`, [
119
+ baseline.when,
120
+ ]);
121
+ if (existing.length > 0) {
122
+ return { status: 'already-baselined', tag: baseline.tag, when: baseline.when };
123
+ }
124
+
125
+ // marker は無いが行が在る=既に別の状態。誤爆防止で中断。
126
+ const countRows = await rowsOf(db, `select count(*) as n from \`${MIGRATIONS_TABLE}\``);
127
+ const rowCount = Number(countRows[0]?.n ?? 0);
128
+ if (rowCount > 0) {
129
+ throw new Error(
130
+ `${MIGRATIONS_TABLE} already has ${rowCount} row(s) but no baseline marker (created_at=${baseline.when}). ` +
131
+ `Migration state is unexpected — refusing to insert. Inspect \`${MIGRATIONS_TABLE}\` manually.`,
132
+ );
133
+ }
134
+
135
+ // 空 DB への baseline は危険(0000 を skip 扱いにするとテーブルが作られない)。brownfield 確認。
136
+ const tableRows = await rowsOf(
137
+ db,
138
+ `select count(*) as n from information_schema.tables
139
+ where table_schema = DATABASE() and table_type = 'BASE TABLE' and table_name <> ?`,
140
+ [MIGRATIONS_TABLE],
141
+ );
142
+ const baseTableCount = Number(tableRows[0]?.n ?? 0);
143
+ if (baseTableCount === 0) {
144
+ throw new Error(
145
+ `Target DB has no base tables. baseline records 0000 as applied WITHOUT creating tables — this is only ` +
146
+ `for existing (brownfield) DBs. For a fresh/empty DB run \`drizzle-kit migrate\` instead.`,
147
+ );
148
+ }
149
+
150
+ await db.query(`insert into \`${MIGRATIONS_TABLE}\` (\`hash\`, \`created_at\`) values (?, ?)`, [
151
+ baseline.hash,
152
+ baseline.when,
153
+ ]);
154
+ return { status: 'inserted', tag: baseline.tag, when: baseline.when, hash: baseline.hash };
155
+ }
@@ -95,6 +95,28 @@ export function honoDrizzleConfig(options: HonoDrizzleConfigOptions) {
95
95
  tablesFilter,
96
96
  introspect,
97
97
  } = options;
98
+ // CI/本番の migrate は AWS Secrets Manager の RDS マネージド secret(キー
99
+ // host/port/dbname/username/password)を `DB_SECRET` にまるごと渡す運用を吸収する。JSON.parse で
100
+ // 解釈するので secret のキー名(host≠DB_HOST)差を map でき、password の特殊文字もシェル安全。
101
+ // 未設定(ローカル/db:generate)は従来どおり個別 env → デフォルトにフォールバック。
102
+ // DB_SECRET が在れば「完全な secret」として全接続情報をそれで確定する(欠損/不正は throw)。
103
+ // 未設定時のみ従来の個別 env → デフォルトにフォールバックする。
104
+ const secret = resolveDbSecret();
105
+ const dbCredentials = secret
106
+ ? {
107
+ host: secret.host,
108
+ port: secret.port,
109
+ user: secret.username,
110
+ password: secret.password,
111
+ database: secret.dbname,
112
+ }
113
+ : {
114
+ host: host ?? process.env.DB_HOST ?? '127.0.0.1',
115
+ port: port ?? Number(process.env.DB_PORT ?? 3306),
116
+ user: user ?? process.env.DB_USER ?? 'root',
117
+ password: password ?? process.env.DB_PASSWORD ?? 'root',
118
+ database,
119
+ };
98
120
  return {
99
121
  dialect: 'mysql' as const,
100
122
  schema,
@@ -102,12 +124,48 @@ export function honoDrizzleConfig(options: HonoDrizzleConfigOptions) {
102
124
  casing: 'snake_case' as const,
103
125
  ...(tablesFilter ? { tablesFilter } : {}),
104
126
  ...(introspect ? { introspect } : {}),
105
- dbCredentials: {
106
- host: host ?? process.env.DB_HOST ?? '127.0.0.1',
107
- port: port ?? Number(process.env.DB_PORT ?? 3306),
108
- user: user ?? process.env.DB_USER ?? 'root',
109
- password: password ?? process.env.DB_PASSWORD ?? 'root',
110
- database,
111
- },
127
+ dbCredentials,
112
128
  };
113
129
  }
130
+
131
+ /** {@link resolveDbSecret} の戻り値(正規化済みの接続情報)。 */
132
+ export interface ResolvedDbSecret {
133
+ host: string;
134
+ port: number;
135
+ dbname: string;
136
+ username: string;
137
+ password: string;
138
+ }
139
+
140
+ /**
141
+ * AWS RDS マネージド secret(`DB_SECRET` に入れた JSON 文字列)を解決する。
142
+ *
143
+ * @remarks
144
+ * - `DB_SECRET` 未設定 → `undefined`(ローカル/`db:generate` の正常フォールバック)。
145
+ * - 設定されている場合は「完全な接続情報」であることを要求し、**不正 JSON / 必須キー欠損は throw**
146
+ * (静かに localhost へフォールバックして事故らせない)。`port` のみ欠損時は 3306 を補う。
147
+ *
148
+ * `honoDrizzleConfig`(db:migrate)と `workers-hono-kit-db-baseline` bin の双方が同じ解釈を使う。
149
+ */
150
+ export function resolveDbSecret(): ResolvedDbSecret | undefined {
151
+ const raw = process.env.DB_SECRET;
152
+ if (!raw) {
153
+ return undefined;
154
+ }
155
+ let parsed: Record<string, unknown>;
156
+ try {
157
+ parsed = JSON.parse(raw) as Record<string, unknown>;
158
+ } catch {
159
+ throw new Error('DB_SECRET is set but is not valid JSON (expected an AWS RDS managed secret string).');
160
+ }
161
+ const { host, dbname, username, password } = parsed;
162
+ if (
163
+ typeof host !== 'string' ||
164
+ typeof dbname !== 'string' ||
165
+ typeof username !== 'string' ||
166
+ typeof password !== 'string'
167
+ ) {
168
+ throw new Error('DB_SECRET must contain string host, dbname, username, password (AWS RDS managed secret shape).');
169
+ }
170
+ return { host, dbname, username, password, port: parsed.port === undefined ? 3306 : Number(parsed.port) };
171
+ }