ework-web 0.2.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
@@ -42,7 +42,8 @@
42
42
  "start": "bun src/index.ts",
43
43
  "check": "tsc --noEmit",
44
44
  "typecheck": "tsc --noEmit",
45
- "test": "bun test"
45
+ "test": "bun test",
46
+ "test:mysql": "bash scripts/test-mysql.sh"
46
47
  },
47
48
  "dependencies": {
48
49
  "@types/dompurify": "^3.2.0",
@@ -51,6 +52,7 @@
51
52
  "jsdom": "^29.1.1",
52
53
  "marked": "^15.0.0",
53
54
  "marked-highlight": "^2.2.0",
55
+ "mysql2": "^3.23.1",
54
56
  "zod": "^3.23.8"
55
57
  },
56
58
  "devDependencies": {
package/src/auth.ts CHANGED
@@ -94,7 +94,7 @@ export async function checkAuth(req: Request, cfg: Config, ip?: string | null):
94
94
  const payload = `${COOKIE_VERSION}.${parsed.login}.${parsed.issued}`;
95
95
  const expected = await hmac(cfg.cookieSecret, payload);
96
96
  if (!ctEqual(parsed.sig, expected)) return { ok: false, user: null };
97
- const user = getUserByLogin(parsed.login);
97
+ const user = await getUserByLogin(parsed.login);
98
98
  if (!user || !user.is_active) return { ok: false, user: null };
99
99
  return { ok: true, user };
100
100
  }
@@ -110,7 +110,7 @@ export async function checkAuth(req: Request, cfg: Config, ip?: string | null):
110
110
  if (!ctEqual(sig, expected) || !ctEqual(token, cfg.authToken)) {
111
111
  return { ok: false, user: null };
112
112
  }
113
- const user = getUserByLogin(cfg.operatorLogin);
113
+ const user = await getUserByLogin(cfg.operatorLogin);
114
114
  if (!user || !user.is_active) return { ok: false, user: null };
115
115
  return { ok: true, user };
116
116
  }
@@ -138,19 +138,19 @@ export async function checkAuth(req: Request, cfg: Config, ip?: string | null):
138
138
  return { ok: false, user: null };
139
139
  }
140
140
 
141
- export function ensureBootstrapAdmin(login: string): UserRow {
142
- const existing = getUserByLogin(login);
141
+ export async function ensureBootstrapAdmin(login: string): Promise<UserRow> {
142
+ const existing = await getUserByLogin(login);
143
143
  if (existing) return existing;
144
- return ensureUser(login, "human");
144
+ return await ensureUser(login, "human");
145
145
  }
146
146
 
147
147
  // Reserved system user for automated actions (cron, import jobs, future CI
148
148
  // integration). kind=system, no password (cannot login via UI). Created on
149
149
  // boot if missing. UI guards prevent disabling/deleting it.
150
- export function ensureBootstrapSystem(login: string): UserRow {
151
- const existing = getUserByLogin(login);
150
+ export async function ensureBootstrapSystem(login: string): Promise<UserRow> {
151
+ const existing = await getUserByLogin(login);
152
152
  if (existing) return existing;
153
- return ensureUser(login, "system");
153
+ return await ensureUser(login, "system");
154
154
  }
155
155
 
156
156
  export function isReservedSystemLogin(login: string, cfg: Config): boolean {
package/src/config.ts CHANGED
@@ -148,8 +148,8 @@ export const SETTINGS_GROUPS: SettingGroup[] = [
148
148
  ];
149
149
  export const DB_OVERRIDABLE: (keyof Config)[] = SETTINGS_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
150
150
 
151
- export function loadConfig(): Config {
152
- const db = getConfigAll();
151
+ export async function loadConfig(): Promise<Config> {
152
+ const db = await getConfigAll();
153
153
  return configSchema.parse({
154
154
  port: process.env.WORK_PORT,
155
155
  host: process.env.WORK_HOST,
@@ -0,0 +1,566 @@
1
+ // DB-backend UI — Phase 1 (pure logic, no HTTP).
2
+ //
3
+ // Three primitives for the migration wizard: testMysqlConnection (probe a
4
+ // target without side effects), migrateSqliteToMysql (copy every table from
5
+ // the running sqlite driver into a throwaway mysql pool), writeMysqlEnv
6
+ // (forward-fill .env). Design spec: docs/db-backend-ui.md §4.1 + §4.3.
7
+ //
8
+ // The throwaway-pool pattern is load-bearing: every function opens a NEW
9
+ // mysql2 pool with caller-supplied creds and closes it before returning. The
10
+ // running driver (getDB(), opened once at boot) is never touched, so the
11
+ // wizard can fail/retry without affecting live traffic.
12
+
13
+ import { createPool, type Pool, type PoolConnection } from "mysql2/promise";
14
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
15
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
16
+ import { join } from "path";
17
+ import { getDB } from "./db";
18
+
19
+ export interface MysqlTargetOpts {
20
+ host: string;
21
+ port: number;
22
+ user: string;
23
+ password: string;
24
+ database: string;
25
+ /** Optional table prefix for the target. Empty = no prefix. Validated
26
+ * against the same identifier rule as WORK_DB_PREFIX in db.ts so generated
27
+ * SQL can't be SQL-injected via the prefix. */
28
+ prefix?: string;
29
+ }
30
+
31
+ export type TestMysqlResult =
32
+ | { ok: true; serverVersion: string; databaseExists: boolean }
33
+ | { ok: false; error: string; hint?: string };
34
+
35
+ export type MigrateResult =
36
+ | { ok: true; tables: { table: string; rows: number }[] }
37
+ | { ok: false; error: string; partial?: string[] };
38
+
39
+ export type WriteEnvResult = { written: string[]; envPath: string };
40
+
41
+ // FK-safe copy order (parents before children). `config` lives first — it's
42
+ // created in db.ts (not in schema.sql), has no FK deps, and holds the
43
+ // migration wizard's own settings. We additionally SET FOREIGN_KEY_CHECKS=0
44
+ // during the copy as belt-and-suspenders; keeping the array FK-ordered still
45
+ // matches what a manual dump/restore would do.
46
+ //
47
+ // When adding a table, update this list AND schema.sql + schema-mysql.sql —
48
+ // otherwise migrate() silently skips the new table.
49
+ export const MIGRATION_TABLE_ORDER = [
50
+ "config",
51
+ "users",
52
+ "projects",
53
+ "model_cache",
54
+ "issues",
55
+ "labels",
56
+ "comments",
57
+ "issue_labels",
58
+ "reactions",
59
+ "attachments",
60
+ "webhooks",
61
+ "personal_access_tokens",
62
+ "project_members",
63
+ "webhook_deliveries",
64
+ ] as const;
65
+
66
+ /** Rewrite {{token}} -> prefix+token. Mirrors db.ts:applyPrefix but takes a
67
+ * caller-supplied prefix — the target may differ from the running driver's
68
+ * (module-private) prefix, and importing DB_PREFIX would couple this module
69
+ * to the boot-time env. */
70
+ function applyTargetPrefix(sql: string, prefix: string): string {
71
+ if (!sql.includes("{{")) return sql;
72
+ return sql.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => prefix + name);
73
+ }
74
+
75
+ function validatePrefix(p: string): string {
76
+ const v = (p ?? "").trim();
77
+ if (v && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(v)) {
78
+ throw new Error(
79
+ `Invalid DB prefix "${v}": must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$`
80
+ );
81
+ }
82
+ return v;
83
+ }
84
+
85
+ /** Redact password-shaped substrings from connection-string-like output.
86
+ * Covers URL-style `://user:pw@host`, querystring `?password=...` /
87
+ * `&password=...`, and shell `password='...'` / `password="..."`. */
88
+ function redactPassword(s: string): string {
89
+ return s
90
+ .replace(/(:\/\/[^:/@]*):[^@/]*@/g, "$1:***@")
91
+ .replace(/[?&]password=[^&\s]*/gi, (m) => m.charAt(0) + "password=***")
92
+ .replace(/password\s*=\s*'[^']*'/gi, "password='***'")
93
+ .replace(/password\s*=\s*"[^"]*"/gi, 'password="***"');
94
+ }
95
+
96
+ function chunk<T>(arr: readonly T[], size: number): T[][] {
97
+ if (size < 1) throw new Error("chunk size must be >= 1");
98
+ const out: T[][] = [];
99
+ for (let i = 0; i < arr.length; i += size) {
100
+ out.push(arr.slice(i, i + size) as T[]);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** Strip `--` comment lines BEFORE splitting on `;` — comment lines may
106
+ * contain `;` and corrupt the split (same trick as db.ts:MysqlDriver.create). */
107
+ function readSchemaMysqlStatements(prefix: string): string[] {
108
+ const raw = readFileSync(join(import.meta.dir, "schema-mysql.sql"), "utf8");
109
+ const stripped = raw
110
+ .split("\n")
111
+ .filter((l) => !l.trimStart().startsWith("--"))
112
+ .join("\n");
113
+ const out: string[] = [];
114
+ for (const stmt of stripped.split(";").map((s) => s.trim()).filter((s) => s.length > 0)) {
115
+ out.push(applyTargetPrefix(stmt, prefix));
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function hintForError(msg: string): string | undefined {
121
+ const lc = msg.toLowerCase();
122
+ if (lc.includes("econnrefused") || lc.includes("enotfound") || lc.includes("etimedout")) {
123
+ return "Check host and port; is MySQL running and reachable from this host?";
124
+ }
125
+ if (lc.includes("access denied") || lc.includes("error 1045") || lc.includes("er_access_denied")) {
126
+ return "Bad username or password.";
127
+ }
128
+ if (lc.includes("unknown database") || lc.includes("error 1049")) {
129
+ return "Create the database first, or grant CREATE to the user so migrate() can create it.";
130
+ }
131
+ if (lc.includes("ssl") || lc.includes("tls")) {
132
+ return "TLS/SSL handshake failed — check MySQL's TLS config and the client's ssl mode.";
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ /** Backtick-quote a SQL identifier. Doubles internal backticks per the MySQL
138
+ * escape rule. */
139
+ function ident(name: string): string {
140
+ return "`" + name.replace(/`/g, "``") + "`";
141
+ }
142
+
143
+ export async function testMysqlConnection(opts: MysqlTargetOpts): Promise<TestMysqlResult> {
144
+ let pool: Pool | null = null;
145
+ let conn: PoolConnection | null = null;
146
+ try {
147
+ // No database selected at handshake — we may be probing a DB that doesn't
148
+ // exist yet (user has CREATE but hasn't run it). Selecting a non-existent
149
+ // DB at connect time would fail the handshake.
150
+ pool = createPool({
151
+ host: opts.host,
152
+ port: opts.port,
153
+ user: opts.user,
154
+ password: opts.password,
155
+ waitForConnections: true,
156
+ connectionLimit: 2,
157
+ charset: "utf8mb4",
158
+ connectTimeout: 10_000,
159
+ });
160
+
161
+ conn = await pool.getConnection();
162
+ // A real query, NOT mysqladmin ping — the mysql:8.0 entrypoint races a
163
+ // temp socket-only server during init; ping succeeds during the temp
164
+ // phase and races the restart. See scripts/test-mysql.sh.
165
+ const [versionRowsRaw] = await conn.query("SELECT VERSION() AS v");
166
+ const versionRows = versionRowsRaw as { v: string }[];
167
+ const version0 = versionRows[0];
168
+ const serverVersion = version0?.v ?? "unknown";
169
+
170
+ const [dbRowsRaw] = await conn.query(
171
+ "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?",
172
+ [opts.database]
173
+ );
174
+ const dbRows = dbRowsRaw as { SCHEMA_NAME: string }[];
175
+ const databaseExists = dbRows.length > 0;
176
+
177
+ // Best-effort privilege check. We can't cleanly ask "do I have CREATE on
178
+ // a not-yet-existent DB?" without parsing SHOW GRANTS (the GRANT syntax
179
+ // has many shapes). If the DB doesn't exist, require CREATE anywhere in
180
+ // the user's grants. False negatives are fine — the user can create the
181
+ // DB by hand; false positives get caught at migrate().
182
+ let hasCreate = true;
183
+ if (!databaseExists) {
184
+ const [grantsRaw] = await conn.query("SHOW GRANTS FOR CURRENT_USER()");
185
+ const grantsRows = grantsRaw as Record<string, string>[];
186
+ const grants = grantsRows
187
+ .map((r) => Object.values(r)[0])
188
+ .filter((v): v is string => typeof v === "string");
189
+ hasCreate = grants.some((g) => /\bCREATE\b/i.test(g));
190
+ }
191
+
192
+ if (!databaseExists && !hasCreate) {
193
+ return {
194
+ ok: false,
195
+ error: `Database "${opts.database}" does not exist and user "${opts.user}" has no CREATE privilege`,
196
+ hint: "Create the database first (CREATE DATABASE ... CHARACTER SET utf8mb4) or grant CREATE on it to the user.",
197
+ };
198
+ }
199
+
200
+ return { ok: true, serverVersion, databaseExists };
201
+ } catch (e) {
202
+ const msg = e instanceof Error ? e.message : String(e);
203
+ return {
204
+ ok: false,
205
+ error: redactPassword(msg),
206
+ hint: hintForError(msg),
207
+ };
208
+ } finally {
209
+ if (conn) {
210
+ try { conn.release(); } catch (e) { console.warn("[db-admin] conn.release failed:", e); }
211
+ }
212
+ if (pool) {
213
+ try { await pool.end(); } catch (e) { console.warn("[db-admin] pool.end failed:", e); }
214
+ }
215
+ }
216
+ }
217
+
218
+ export async function migrateSqliteToMysql(opts: MysqlTargetOpts): Promise<MigrateResult> {
219
+ const prefix = validatePrefix(opts.prefix ?? "");
220
+ let pool: Pool | null = null;
221
+ let conn: PoolConnection | null = null;
222
+ const completed: { table: string; rows: number }[] = [];
223
+
224
+ try {
225
+ // Pool WITHOUT `database`: if the target DB doesn't exist yet (the common
226
+ // migration-wizard case), selecting it at handshake would fail with
227
+ // "Unknown database". We CREATE + USE on a single held connection below.
228
+ //
229
+ // Holding ONE connection for the whole migrate() is load-bearing:
230
+ // USE, FOREIGN_KEY_CHECKS, and transactions are all session-scoped. With
231
+ // a pool, sequential pool.query() calls can land on different underlying
232
+ // connections, so session state set on one wouldn't apply to the next.
233
+ // One held connection makes that state deterministic.
234
+ pool = createPool({
235
+ host: opts.host,
236
+ port: opts.port,
237
+ user: opts.user,
238
+ password: opts.password,
239
+ waitForConnections: true,
240
+ connectionLimit: 2,
241
+ charset: "utf8mb4",
242
+ });
243
+
244
+ conn = await pool.getConnection();
245
+
246
+ await conn.query(
247
+ `CREATE DATABASE IF NOT EXISTS ${ident(opts.database)} CHARACTER SET utf8mb4`
248
+ );
249
+ await conn.query(`USE ${ident(opts.database)}`);
250
+
251
+ // The `config` table is created in db.ts:SqliteDriver.create at boot but
252
+ // is missing from schema-mysql.sql (pre-existing gap — MysqlDriver.create
253
+ // doesn't create it either, so a plain mysql boot has no config table).
254
+ // Recreate the same shape here so migrate() can copy config rows. `key`
255
+ // is a MySQL reserved word — must be backticked. VARCHAR(255) on the PK
256
+ // because MySQL TEXT can't be a PRIMARY KEY without a prefix length.
257
+ await conn.query(
258
+ applyTargetPrefix(
259
+ "CREATE TABLE IF NOT EXISTS {{config}} (" +
260
+ "`key` VARCHAR(255) PRIMARY KEY," +
261
+ "value TEXT NOT NULL," +
262
+ "updated_at VARCHAR(40) NOT NULL" +
263
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
264
+ prefix
265
+ )
266
+ );
267
+
268
+ // Apply schema-mysql.sql. CREATE INDEX lacks IF NOT EXISTS in MySQL, so
269
+ // tolerate ER_DUP_KEYNAME (1061) for re-runs.
270
+ for (const stmt of readSchemaMysqlStatements(prefix)) {
271
+ try {
272
+ await conn.query(stmt);
273
+ } catch (e) {
274
+ if (
275
+ e && typeof e === "object" && "errno" in e &&
276
+ (e as { errno: number }).errno === 1061
277
+ ) {
278
+ continue;
279
+ }
280
+ throw e;
281
+ }
282
+ }
283
+
284
+ // Idempotent re-run: TRUNCATE resets data + AUTO_INCREMENT. FK_CHECKS=0
285
+ // lets us TRUNCATE tables that are referenced by others (TRUNCATE
286
+ // otherwise refuses even on empty data). Stays 0 for the whole copy
287
+ // phase; reset to 1 once every table has been inserted + verified.
288
+ await conn.query("SET FOREIGN_KEY_CHECKS = 0");
289
+ for (const t of MIGRATION_TABLE_ORDER) {
290
+ await conn.query(`TRUNCATE TABLE ${ident(prefix + t)}`);
291
+ }
292
+
293
+ const sqlite = getDB();
294
+ for (const table of MIGRATION_TABLE_ORDER) {
295
+ const rows = await sqlite.all<Record<string, unknown>>(
296
+ `SELECT * FROM {{${table}}}`
297
+ );
298
+ if (rows.length === 0) {
299
+ completed.push({ table, rows: 0 });
300
+ continue;
301
+ }
302
+ const first = rows[0];
303
+ if (!first) {
304
+ // Unreachable given length>0; noUncheckedIndexedAccess forces the guard.
305
+ completed.push({ table, rows: 0 });
306
+ continue;
307
+ }
308
+ const cols = Object.keys(first);
309
+ if (cols.length === 0) {
310
+ completed.push({ table, rows: rows.length });
311
+ continue;
312
+ }
313
+
314
+ // `INSERT INTO t (a,b) VALUES ?` with a 2D array — mysql2 expands the
315
+ // nested arrays into a comma-separated value list. undefined → null
316
+ // (sqlite rows shouldn't have undefined fields, but JS-side defaults
317
+ // sometimes do; mysql2 rejects undefined).
318
+ const insertSql =
319
+ `INSERT INTO ${ident(prefix + table)}` +
320
+ ` (${cols.map(ident).join(",")}) VALUES ?`;
321
+
322
+ // Per-table transaction: a bad batch rolls back THIS table's inserts
323
+ // without touching earlier tables. (DDL above already auto-committed;
324
+ // only the INSERTs are transactional.)
325
+ try {
326
+ await conn.beginTransaction();
327
+ for (const batch of chunk(rows, 500)) {
328
+ const matrix: unknown[][] = batch.map((r) =>
329
+ cols.map((c) => {
330
+ const v = r[c];
331
+ return v === undefined ? null : v;
332
+ })
333
+ );
334
+ await conn.query(insertSql, [matrix]);
335
+ }
336
+ await conn.commit();
337
+ } catch (e) {
338
+ try { await conn.rollback(); } catch (rollbackErr) {
339
+ console.warn("[db-admin] rollback failed for", table, ":", rollbackErr);
340
+ }
341
+ throw e;
342
+ }
343
+
344
+ const [cntRowsRaw] = await conn.query(
345
+ `SELECT COUNT(*) AS c FROM ${ident(prefix + table)}`
346
+ );
347
+ const cntRows = cntRowsRaw as { c: number }[];
348
+ const cnt0 = cntRows[0];
349
+ const mysqlCount = cnt0?.c ?? -1;
350
+ if (mysqlCount !== rows.length) {
351
+ throw new Error(
352
+ `Row-count mismatch for ${prefix}${table}: sqlite=${rows.length} mysql=${mysqlCount}`
353
+ );
354
+ }
355
+ completed.push({ table, rows: rows.length });
356
+ }
357
+
358
+ await conn.query("SET FOREIGN_KEY_CHECKS = 1");
359
+ return { ok: true, tables: completed };
360
+ } catch (e) {
361
+ const msg = e instanceof Error ? e.message : String(e);
362
+ // DDL above auto-committed (MySQL implicit commit on DDL), so ROLLBACK
363
+ // wouldn't undo CREATE TABLE. Explicitly DROP every half-built table so
364
+ // the next migrate() call starts from a clean target.
365
+ if (conn) {
366
+ try {
367
+ await conn.query("SET FOREIGN_KEY_CHECKS = 0");
368
+ for (const t of MIGRATION_TABLE_ORDER) {
369
+ await conn.query(`DROP TABLE IF EXISTS ${ident(prefix + t)}`);
370
+ }
371
+ await conn.query("SET FOREIGN_KEY_CHECKS = 1");
372
+ } catch (cleanupErr) {
373
+ // The migrate() failure is the primary signal. A cleanup failure
374
+ // leaves stale tables; the next migrate() run will TRUNCATE them.
375
+ console.warn("[db-admin] cleanup DROP TABLES failed:", cleanupErr);
376
+ }
377
+ }
378
+ return {
379
+ ok: false,
380
+ error: redactPassword(msg),
381
+ partial: completed.map((c) => `${c.table}=${c.rows}`),
382
+ };
383
+ } finally {
384
+ if (conn) {
385
+ try { conn.release(); } catch (e) { console.warn("[db-admin] conn.release failed:", e); }
386
+ }
387
+ if (pool) {
388
+ try { await pool.end(); } catch (e) { console.warn("[db-admin] pool.end failed:", e); }
389
+ }
390
+ }
391
+ }
392
+
393
+ export async function writeMysqlEnv(
394
+ envPath: string,
395
+ opts: MysqlTargetOpts
396
+ ): Promise<WriteEnvResult> {
397
+ const prefix = validatePrefix(opts.prefix ?? "");
398
+
399
+ // PASSWORD is written verbatim — the .env file is mode 0600 (deploy.sh
400
+ // enforces) and the existing .env format is bare KEY=VALUE (see
401
+ // .env.example). If a password contains '#' or whitespace, the user must
402
+ // hand-quote; we don't try to be smart about shell quoting because Bun's
403
+ // env loader / systemd EnvironmentFile is the source of truth.
404
+ const desired: Record<string, string> = {
405
+ WORK_DB_DRIVER: "mysql",
406
+ WORK_DB_HOST: opts.host,
407
+ WORK_DB_PORT: String(opts.port),
408
+ WORK_DB_USER: opts.user,
409
+ WORK_DB_PASSWORD: opts.password,
410
+ WORK_DB_NAME: opts.database,
411
+ };
412
+ if (prefix) desired.WORK_DB_PREFIX = prefix;
413
+
414
+ const linesIn = existsSync(envPath)
415
+ ? readFileSync(envPath, "utf8").split(/\r?\n/)
416
+ : [];
417
+
418
+ const written: string[] = [];
419
+ const seen = new Set<string>();
420
+ const out: string[] = [];
421
+
422
+ // First pass: update existing keys in place (preserves comments + order).
423
+ // Matches the project's .env style: `KEY=VALUE`, no surrounding quotes.
424
+ for (const line of linesIn) {
425
+ const m = line.match(/^([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/);
426
+ if (m) {
427
+ const key = m[1] ?? "";
428
+ if (key in desired) {
429
+ out.push(`${key}=${desired[key] ?? ""}`);
430
+ written.push(key);
431
+ seen.add(key);
432
+ continue;
433
+ }
434
+ }
435
+ out.push(line);
436
+ }
437
+
438
+ // Second pass: append missing keys under a legible banner.
439
+ const missing = Object.keys(desired).filter((k) => !seen.has(k));
440
+ if (missing.length > 0) {
441
+ if (out.length > 0 && out[out.length - 1] !== "") out.push("");
442
+ out.push("# ework-web: MySQL backend (written by db-admin migration)");
443
+ for (const k of missing) {
444
+ out.push(`${k}=${desired[k] ?? ""}`);
445
+ written.push(k);
446
+ }
447
+ }
448
+
449
+ writeFileSync(envPath, out.join("\n") + "\n", "utf8");
450
+ return { written, envPath };
451
+ }
452
+
453
+ // Reverse migration: MySQL → SQLite. Creates a fresh SQLite file at targetPath,
454
+ // applies schema.sql, copies all 14 tables from the running MySQL driver.
455
+ // Used as the safety net when the MySQL switch goes wrong.
456
+ export async function migrateMysqlToSqlite(targetPath: string): Promise<MigrateResult> {
457
+ const target = new Database(targetPath, { create: true, readwrite: true });
458
+ const completed: { table: string; rows: number }[] = [];
459
+
460
+ try {
461
+ target.exec("PRAGMA journal_mode = WAL");
462
+ target.exec("PRAGMA foreign_keys = ON");
463
+
464
+ // config table (same shape as db.ts:144 — not in schema.sql).
465
+ // `key` is NOT reserved in SQLite (unlike MySQL).
466
+ target.exec(
467
+ applyTargetPrefix(
468
+ "CREATE TABLE IF NOT EXISTS {{config}} (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)",
469
+ ""
470
+ )
471
+ );
472
+
473
+ // schema.sql — bun:sqlite's exec handles the whole file (comments + multi-statement).
474
+ const schema = readFileSync(join(import.meta.dir, "schema.sql"), "utf8");
475
+ target.exec(applyTargetPrefix(schema, ""));
476
+
477
+ const source = getDB();
478
+ for (const table of MIGRATION_TABLE_ORDER) {
479
+ const rows = await source.all<Record<string, unknown>>(`SELECT * FROM {{${table}}}`);
480
+ if (rows.length === 0) {
481
+ completed.push({ table, rows: 0 });
482
+ continue;
483
+ }
484
+ const first = rows[0];
485
+ if (!first) {
486
+ completed.push({ table, rows: 0 });
487
+ continue;
488
+ }
489
+ const cols = Object.keys(first);
490
+ if (cols.length === 0) {
491
+ completed.push({ table, rows: rows.length });
492
+ continue;
493
+ }
494
+
495
+ const placeholders = cols.map(() => "?").join(",");
496
+ const insertSql = `INSERT INTO ${table} (${cols.join(",")}) VALUES (${placeholders})`;
497
+
498
+ target.transaction(() => {
499
+ for (const row of rows) {
500
+ const vals = cols.map((c) => {
501
+ const v = (row as Record<string, unknown>)[c];
502
+ return v === undefined ? null : v;
503
+ });
504
+ target.query(insertSql).run(...(vals as SQLQueryBindings[]));
505
+ }
506
+ })();
507
+
508
+ const countRow = target.query(`SELECT COUNT(*) AS c FROM ${table}`).get() as { c: number } | null;
509
+ const count = countRow?.c ?? -1;
510
+ if (count !== rows.length) {
511
+ throw new Error(`Row-count mismatch for ${table}: mysql=${rows.length} sqlite=${count}`);
512
+ }
513
+ completed.push({ table, rows: rows.length });
514
+ }
515
+
516
+ target.close();
517
+ return { ok: true, tables: completed };
518
+ } catch (e) {
519
+ const msg = e instanceof Error ? e.message : String(e);
520
+ try { target.close(); } catch { /* already closed */ }
521
+ try { unlinkSync(targetPath); } catch { /* file may not exist */ }
522
+ return { ok: false, error: redactPassword(msg), partial: completed.map((c) => `${c.table}=${c.rows}`) };
523
+ }
524
+ }
525
+
526
+ export async function writeSqliteEnv(envPath: string, dbPath: string): Promise<WriteEnvResult> {
527
+ const desired: Record<string, string> = {
528
+ WORK_DB_DRIVER: "sqlite",
529
+ WORK_DB_PATH: dbPath,
530
+ };
531
+
532
+ const linesIn = existsSync(envPath)
533
+ ? readFileSync(envPath, "utf8").split(/\r?\n/)
534
+ : [];
535
+
536
+ const written: string[] = [];
537
+ const seen = new Set<string>();
538
+ const out: string[] = [];
539
+
540
+ for (const line of linesIn) {
541
+ const m = line.match(/^([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/);
542
+ if (m) {
543
+ const key = m[1] ?? "";
544
+ if (key in desired) {
545
+ out.push(`${key}=${desired[key] ?? ""}`);
546
+ written.push(key);
547
+ seen.add(key);
548
+ continue;
549
+ }
550
+ }
551
+ out.push(line);
552
+ }
553
+
554
+ const missing = Object.keys(desired).filter((k) => !seen.has(k));
555
+ if (missing.length > 0) {
556
+ if (out.length > 0 && out[out.length - 1] !== "") out.push("");
557
+ out.push("# ework-web: revert to SQLite (written by db-admin migration)");
558
+ for (const k of missing) {
559
+ out.push(`${k}=${desired[k] ?? ""}`);
560
+ written.push(k);
561
+ }
562
+ }
563
+
564
+ writeFileSync(envPath, out.join("\n") + "\n", "utf8");
565
+ return { written, envPath };
566
+ }