ework-web 0.4.3 → 0.4.4

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.4.3",
3
+ "version": "0.4.4",
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",
package/src/db-admin.ts CHANGED
@@ -33,7 +33,7 @@ export type TestMysqlResult =
33
33
  | { ok: false; error: string; hint?: string };
34
34
 
35
35
  export type MigrateResult =
36
- | { ok: true; tables: { table: string; rows: number }[] }
36
+ | { ok: true; tables: { table: string; rows: number }[]; skipped?: string }
37
37
  | { ok: false; error: string; partial?: string[] };
38
38
 
39
39
  export type WriteEnvResult = { written: string[]; envPath: string };
@@ -523,6 +523,143 @@ export async function migrateMysqlToSqlite(targetPath: string): Promise<MigrateR
523
523
  }
524
524
  }
525
525
 
526
+ // The daemon has its own SQLite DB separate from the web's. When switching
527
+ // to MySQL, its old session data must be copied too. MySQL tables are
528
+ // created with basic columns matching the daemon's SQLite schema;
529
+ // coordination columns (owner_daemon_id, etc.) are added by the daemon's
530
+ // own ALTER TABLE migration on boot.
531
+
532
+ const DAEMON_TABLES = ["issues", "op_sessions", "messages"] as const;
533
+ const DAEMON_DDL: Record<string, string> = {
534
+ issues:
535
+ "CREATE TABLE IF NOT EXISTS {{t}} (" +
536
+ "id VARCHAR(36) PRIMARY KEY," +
537
+ "tracker_type VARCHAR(32) NOT NULL," +
538
+ "tracker_scope_key VARCHAR(255) NOT NULL," +
539
+ "tracker_scope TEXT NOT NULL," +
540
+ "tracker_issue_id VARCHAR(64) NOT NULL," +
541
+ "state VARCHAR(16) NOT NULL DEFAULT 'created'," +
542
+ "title TEXT NOT NULL," +
543
+ "created_at VARCHAR(40) NOT NULL," +
544
+ "updated_at VARCHAR(40) NOT NULL," +
545
+ "UNIQUE(tracker_type, tracker_scope_key, tracker_issue_id)" +
546
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
547
+ op_sessions:
548
+ "CREATE TABLE IF NOT EXISTS {{t}} (" +
549
+ "id VARCHAR(36) PRIMARY KEY," +
550
+ "issue_id VARCHAR(36) NOT NULL," +
551
+ "name VARCHAR(255) NOT NULL," +
552
+ "state VARCHAR(16) NOT NULL DEFAULT 'idle'," +
553
+ "opencode_session_id VARCHAR(64)," +
554
+ "opencode_pid BIGINT," +
555
+ "workdir TEXT," +
556
+ "created_at VARCHAR(40) NOT NULL," +
557
+ "started_at BIGINT," +
558
+ "progress_comment_id VARCHAR(64)," +
559
+ "reaction_comment_id VARCHAR(64)," +
560
+ "current_prompt MEDIUMTEXT," +
561
+ "UNIQUE(issue_id, name)" +
562
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
563
+ messages:
564
+ "CREATE TABLE IF NOT EXISTS {{t}} (" +
565
+ "id VARCHAR(36) PRIMARY KEY," +
566
+ "session_id VARCHAR(36) NOT NULL," +
567
+ "content MEDIUMTEXT NOT NULL," +
568
+ "source_comment_id VARCHAR(64)," +
569
+ "reaction_comment_id VARCHAR(64)," +
570
+ "status VARCHAR(16) NOT NULL DEFAULT 'pending'," +
571
+ "attempts INT NOT NULL DEFAULT 0," +
572
+ "error TEXT," +
573
+ "created_at VARCHAR(40) NOT NULL," +
574
+ "updated_at VARCHAR(40) NOT NULL" +
575
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
576
+ };
577
+
578
+ export async function migrateDaemonSqliteToMysql(
579
+ sqlitePath: string,
580
+ opts: MysqlTargetOpts,
581
+ ): Promise<MigrateResult> {
582
+ if (!existsSync(sqlitePath)) {
583
+ return { ok: true, tables: [], skipped: "no daemon sqlite file found" };
584
+ }
585
+ const prefix = validatePrefix(opts.prefix ?? "");
586
+ let pool: Pool | null = null;
587
+ let conn: PoolConnection | null = null;
588
+ const completed: { table: string; rows: number }[] = [];
589
+
590
+ try {
591
+ const probe = new Database(sqlitePath, { readonly: true });
592
+ const tableNames = probe
593
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
594
+ .all() as { name: string }[];
595
+ probe.close();
596
+
597
+ const hasDaemonTables = DAEMON_TABLES.every((t) => tableNames.some((r) => r.name === t));
598
+ if (!hasDaemonTables) {
599
+ return { ok: true, tables: [], skipped: "daemon sqlite has no daemon tables" };
600
+ }
601
+
602
+ pool = createPool({
603
+ host: opts.host,
604
+ port: opts.port,
605
+ user: opts.user,
606
+ password: opts.password,
607
+ database: opts.database,
608
+ waitForConnections: true,
609
+ connectionLimit: 2,
610
+ charset: "utf8mb4",
611
+ });
612
+ conn = await pool.getConnection();
613
+
614
+ await conn.query("SET FOREIGN_KEY_CHECKS = 0");
615
+
616
+ for (const table of DAEMON_TABLES) {
617
+ const ddl = applyTargetPrefix(DAEMON_DDL[table]!.replace(/{{t}}/g, `{{${table}}}`), prefix);
618
+ await conn.query(ddl);
619
+ await conn.query(`TRUNCATE TABLE ${ident(prefix + table)}`);
620
+ }
621
+
622
+ const sqliteRo = new Database(sqlitePath, { readonly: true });
623
+ for (const table of DAEMON_TABLES) {
624
+ const rows = sqliteRo.query(`SELECT * FROM ${table}`).all() as Record<string, unknown>[];
625
+ if (rows.length === 0) {
626
+ completed.push({ table, rows: 0 });
627
+ continue;
628
+ }
629
+ const cols = Object.keys(rows[0]!);
630
+ const placeholders = cols.map(() => "?").join(",");
631
+ const insertSql = `INSERT INTO ${ident(prefix + table)} (${cols.map((c) => ident(c)).join(",")}) VALUES (${placeholders})`;
632
+ for (let i = 0; i < rows.length; i += 500) {
633
+ const batch = rows.slice(i, i + 500);
634
+ for (const row of batch) {
635
+ const vals = cols.map((c) => {
636
+ const v = row[c];
637
+ return v === undefined ? null : v;
638
+ });
639
+ await conn.query(insertSql, vals);
640
+ }
641
+ }
642
+ const [countRows] = await conn.query(`SELECT COUNT(*) AS c FROM ${ident(prefix + table)}`);
643
+ const countRow = (countRows as Record<string, unknown>[])[0];
644
+ const count = (countRow?.c as number) ?? -1;
645
+ if (count !== rows.length) {
646
+ throw new Error(`Row-count mismatch for daemon ${table}: sqlite=${rows.length} mysql=${count}`);
647
+ }
648
+ completed.push({ table, rows: rows.length });
649
+ }
650
+ sqliteRo.close();
651
+
652
+ await conn.query("SET FOREIGN_KEY_CHECKS = 1");
653
+ return { ok: true, tables: completed };
654
+ } catch (e) {
655
+ const msg = e instanceof Error ? e.message : String(e);
656
+ return { ok: false, error: redactPassword(msg), partial: completed.map((c) => `${c.table}=${c.rows}`) };
657
+ } finally {
658
+ if (conn) conn.release();
659
+ if (pool) await pool.end();
660
+ }
661
+ }
662
+
526
663
  export async function writeSqliteEnv(envPath: string, dbPath: string): Promise<WriteEnvResult> {
527
664
  const desired: Record<string, string> = {
528
665
  WORK_DB_DRIVER: "sqlite",
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "os";
6
6
  import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
7
7
  import type { Config } from "./config";
8
8
  import { setConfig, initDB } from "./db";
9
- import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv } from "./db-admin";
9
+ import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql } from "./db-admin";
10
10
  import type { MysqlTargetOpts } from "./db-admin";
11
11
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
12
12
  import { OpencodeClient, OpencodeError } from "./opencode";
@@ -247,6 +247,16 @@ function daemonEnvPath(): string | null {
247
247
  return null;
248
248
  }
249
249
 
250
+ function readDaemonSqlitePath(envPath: string): string | null {
251
+ try {
252
+ const content = readFileSync(envPath, "utf8");
253
+ const m = content.match(/^DAEMON_DB_PATH\s*=\s*(.+)$/m);
254
+ return m?.[1]?.trim() ?? null;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
250
260
  function spawnDaemonRestart(): void {
251
261
  try {
252
262
  const child = spawn("ework-aio", ["restart", "daemon"], { detached: true, stdio: "ignore" });
@@ -748,9 +758,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
748
758
  return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
749
759
  }
750
760
  try {
761
+ const daemonDbPath = readDaemonSqlitePath(envPath);
762
+ let daemonMigrated: { table: string; rows: number }[] | null = null;
763
+ if (daemonDbPath) {
764
+ const mig = await migrateDaemonSqliteToMysql(daemonDbPath, daemonOpts);
765
+ if (mig.ok) daemonMigrated = mig.tables;
766
+ }
751
767
  const written = await writeMysqlEnv(envPath, daemonOpts);
752
768
  spawnDaemonRestart();
753
- return json({ ok: true, configured: true, envPath, written: written.written });
769
+ return json({ ok: true, configured: true, envPath, written: written.written, daemonMigrated });
754
770
  } catch (e) {
755
771
  return json({ ok: false, configured: false, error: errMsg(e), manual: daemonManualInstructions(daemonOpts) });
756
772
  }