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 +4 -2
- package/src/auth.ts +8 -8
- package/src/config.ts +2 -2
- package/src/db-admin.ts +566 -0
- package/src/db.ts +286 -49
- package/src/giteaApi.ts +53 -53
- package/src/index.ts +261 -102
- package/src/reactions.ts +2 -2
- package/src/schema-mysql.sql +178 -0
- package/src/schema.sql +40 -40
- package/src/store.ts +339 -379
- package/src/views/home.ts +10 -9
- package/src/views/issueList.ts +4 -4
- package/src/views/issueThread.ts +21 -21
- package/src/views/issues.ts +3 -3
- package/src/views/projectMembers.ts +8 -9
- package/src/views/projectUpstreams.ts +3 -3
- package/src/views/settings.ts +82 -0
- package/src/webhooks.ts +63 -73
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-web",
|
|
3
|
-
"version": "0.
|
|
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,
|
package/src/db-admin.ts
ADDED
|
@@ -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
|
+
}
|