ework-web 0.10.19 → 0.10.21
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 +1 -1
- package/src/db-admin.ts +8 -10
- package/src/db.ts +115 -6
- package/src/index.ts +35 -5
- package/src/schema-mysql.sql +29 -23
- package/src/schema.sql +28 -23
- package/src/store.ts +37 -24
- package/src/views/home.ts +3 -2
package/package.json
CHANGED
package/src/db-admin.ts
CHANGED
|
@@ -251,13 +251,12 @@ export async function migrateSqliteToMysql(opts: MysqlTargetOpts): Promise<Migra
|
|
|
251
251
|
// The `config` table is created in db.ts:SqliteDriver.create at boot but
|
|
252
252
|
// is missing from schema-mysql.sql (pre-existing gap — MysqlDriver.create
|
|
253
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.
|
|
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.
|
|
254
|
+
// Recreate the same shape here so migrate() can copy config rows.
|
|
257
255
|
await conn.query(
|
|
258
256
|
applyTargetPrefix(
|
|
259
257
|
"CREATE TABLE IF NOT EXISTS {{config}} (" +
|
|
260
|
-
"
|
|
258
|
+
"id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
259
|
+
"akey VARCHAR(255) NOT NULL UNIQUE," +
|
|
261
260
|
"value TEXT NOT NULL," +
|
|
262
261
|
"updated_at VARCHAR(40) NOT NULL" +
|
|
263
262
|
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
@@ -461,11 +460,10 @@ export async function migrateMysqlToSqlite(targetPath: string): Promise<MigrateR
|
|
|
461
460
|
target.exec("PRAGMA journal_mode = WAL");
|
|
462
461
|
target.exec("PRAGMA foreign_keys = ON");
|
|
463
462
|
|
|
464
|
-
// config table (same shape as db.ts:
|
|
465
|
-
// `key` is NOT reserved in SQLite (unlike MySQL).
|
|
463
|
+
// config table (same shape as db.ts:158 — not in schema.sql).
|
|
466
464
|
target.exec(
|
|
467
465
|
applyTargetPrefix(
|
|
468
|
-
"CREATE TABLE IF NOT EXISTS {{config}} (
|
|
466
|
+
"CREATE TABLE IF NOT EXISTS {{config}} (id INTEGER PRIMARY KEY AUTOINCREMENT, akey TEXT NOT NULL UNIQUE, value TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
|
469
467
|
""
|
|
470
468
|
)
|
|
471
469
|
);
|
|
@@ -609,12 +607,12 @@ export function generateMysqlDDL(
|
|
|
609
607
|
}
|
|
610
608
|
|
|
611
609
|
// config table — created in db.ts:SqliteDriver.create at boot, missing from
|
|
612
|
-
// schema-mysql.sql. Same shape as migrateSqliteToMysql L257-266.
|
|
613
|
-
// MySQL reserved word → backticked.
|
|
610
|
+
// schema-mysql.sql. Same shape as migrateSqliteToMysql L257-266.
|
|
614
611
|
lines.push(
|
|
615
612
|
applyTargetPrefix(
|
|
616
613
|
"CREATE TABLE IF NOT EXISTS {{config}} (" +
|
|
617
|
-
"
|
|
614
|
+
"id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
615
|
+
"akey VARCHAR(255) NOT NULL UNIQUE," +
|
|
618
616
|
"value TEXT NOT NULL," +
|
|
619
617
|
"updated_at VARCHAR(40) NOT NULL" +
|
|
620
618
|
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
package/src/db.ts
CHANGED
|
@@ -141,6 +141,75 @@ function migrateLabelsTable(db: Database): void {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function migrateConfigTable(db: Database): void {
|
|
145
|
+
const have = tableColumns(db, "config");
|
|
146
|
+
if (have.size === 0) return;
|
|
147
|
+
if (have.has("id") && have.has("akey")) return;
|
|
148
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
149
|
+
try {
|
|
150
|
+
const oldKeyCol = have.has("key") ? "key" : "akey";
|
|
151
|
+
db.exec(applyPrefix("ALTER TABLE {{config}} RENAME TO config_old"));
|
|
152
|
+
db.exec(applyPrefix(
|
|
153
|
+
"CREATE TABLE {{config}} (id INTEGER PRIMARY KEY AUTOINCREMENT, akey TEXT NOT NULL UNIQUE, value TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
|
154
|
+
));
|
|
155
|
+
db.exec(applyPrefix(
|
|
156
|
+
`INSERT INTO {{config}} (akey, value, updated_at) SELECT ${oldKeyCol}, value, updated_at FROM config_old`
|
|
157
|
+
));
|
|
158
|
+
db.exec("DROP TABLE config_old");
|
|
159
|
+
} finally {
|
|
160
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const SURROGATE_ID_TABLES: Array<{ name: string; createSql: string; dataCols: string }> = [
|
|
165
|
+
{
|
|
166
|
+
name: "users",
|
|
167
|
+
createSql: "CREATE TABLE {{users}} (id INTEGER PRIMARY KEY AUTOINCREMENT, login TEXT NOT NULL UNIQUE, kind TEXT NOT NULL DEFAULT 'human' CHECK (kind IN ('human','bot','system')), display_name TEXT, password_hash TEXT, email TEXT, is_admin INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT '')",
|
|
168
|
+
dataCols: "login, kind, display_name, password_hash, email, is_admin, is_active, created_at, updated_at",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "model_cache",
|
|
172
|
+
createSql: "CREATE TABLE {{model_cache}} (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_model TEXT NOT NULL UNIQUE, label TEXT NOT NULL, refreshed_at TEXT NOT NULL)",
|
|
173
|
+
dataCols: "provider_model, label, refreshed_at",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "issue_labels",
|
|
177
|
+
createSql: "CREATE TABLE {{issue_labels}} (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id INTEGER NOT NULL REFERENCES {{issues}}(id) ON DELETE CASCADE, label_id INTEGER NOT NULL REFERENCES {{labels}}(id) ON DELETE CASCADE, UNIQUE (issue_id, label_id))",
|
|
178
|
+
dataCols: "issue_id, label_id",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "reactions",
|
|
182
|
+
createSql: "CREATE TABLE {{reactions}} (id INTEGER PRIMARY KEY AUTOINCREMENT, comment_id INTEGER NOT NULL REFERENCES {{comments}}(id) ON DELETE CASCADE, user_login TEXT NOT NULL REFERENCES {{users}}(login), content TEXT NOT NULL, UNIQUE (comment_id, user_login, content))",
|
|
183
|
+
dataCols: "comment_id, user_login, content",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "attachments",
|
|
187
|
+
createSql: "CREATE TABLE {{attachments}} (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, issue_id INTEGER NOT NULL REFERENCES {{issues}}(id) ON DELETE CASCADE, filename TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'application/octet-stream', size INTEGER NOT NULL, blob_path TEXT NOT NULL, uploaded_by TEXT NOT NULL REFERENCES {{users}}(login), created_at TEXT NOT NULL)",
|
|
188
|
+
dataCols: "uuid, issue_id, filename, content_type, size, blob_path, uploaded_by, created_at",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "project_members",
|
|
192
|
+
createSql: "CREATE TABLE {{project_members}} (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE, user_login TEXT NOT NULL REFERENCES {{users}}(login) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'writer' CHECK (role IN ('reader','writer','admin')), created_at TEXT NOT NULL, UNIQUE (project_id, user_login))",
|
|
193
|
+
dataCols: "project_id, user_login, role, created_at",
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
function migrateAddSurrogateId(db: Database): void {
|
|
198
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
199
|
+
try {
|
|
200
|
+
for (const t of SURROGATE_ID_TABLES) {
|
|
201
|
+
const have = tableColumns(db, t.name);
|
|
202
|
+
if (have.size === 0 || have.has("id")) continue;
|
|
203
|
+
db.exec(applyPrefix(`ALTER TABLE {{${t.name}}} RENAME TO ${t.name}_old`));
|
|
204
|
+
db.exec(applyPrefix(t.createSql));
|
|
205
|
+
db.exec(applyPrefix(`INSERT INTO {{${t.name}}} (${t.dataCols}) SELECT ${t.dataCols} FROM ${t.name}_old`));
|
|
206
|
+
db.exec(`DROP TABLE ${t.name}_old`);
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
144
213
|
// ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ----
|
|
145
214
|
class SqliteDriver implements AsyncDatabase {
|
|
146
215
|
readonly dialect = "sqlite" as const;
|
|
@@ -155,15 +224,17 @@ class SqliteDriver implements AsyncDatabase {
|
|
|
155
224
|
db.exec("PRAGMA foreign_keys = ON");
|
|
156
225
|
db.exec(
|
|
157
226
|
applyPrefix(
|
|
158
|
-
"CREATE TABLE IF NOT EXISTS {{config}} (
|
|
227
|
+
"CREATE TABLE IF NOT EXISTS {{config}} (id INTEGER PRIMARY KEY AUTOINCREMENT, akey TEXT NOT NULL UNIQUE, value TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
|
159
228
|
)
|
|
160
229
|
);
|
|
161
230
|
// Migration must run BEFORE schema.sql (same ordering as the original file).
|
|
231
|
+
migrateConfigTable(db);
|
|
162
232
|
migrateUsersTable(db);
|
|
163
233
|
migratePatTable(db);
|
|
164
234
|
migrateProjectsTable(db);
|
|
165
235
|
migrateIssuesTable(db);
|
|
166
236
|
migrateLabelsTable(db);
|
|
237
|
+
migrateAddSurrogateId(db);
|
|
167
238
|
db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
|
|
168
239
|
return new SqliteDriver(db);
|
|
169
240
|
}
|
|
@@ -230,6 +301,43 @@ function translateForMysql(sql: string): string {
|
|
|
230
301
|
.replace(/excluded\.(\w+)/g, "VALUES($1)");
|
|
231
302
|
}
|
|
232
303
|
|
|
304
|
+
async function migrateMysqlSurrogateId(pool: Pool): Promise<void> {
|
|
305
|
+
const MYSQL_ALTERS: Array<{ table: string; sql: string }> = [
|
|
306
|
+
{ table: "users", sql: "ALTER TABLE {{users}} DROP PRIMARY KEY, ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE INDEX uk_users_login (login)" },
|
|
307
|
+
{ table: "model_cache", sql: "ALTER TABLE {{model_cache}} DROP PRIMARY KEY, ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE INDEX uk_mc_pm (provider_model)" },
|
|
308
|
+
{ table: "issue_labels", sql: "ALTER TABLE {{issue_labels}} DROP PRIMARY KEY, ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE INDEX uk_il (issue_id, label_id)" },
|
|
309
|
+
{ table: "reactions", sql: "ALTER TABLE {{reactions}} DROP PRIMARY KEY, ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE INDEX uk_react (comment_id, user_login, content)" },
|
|
310
|
+
{ table: "attachments", sql: "ALTER TABLE {{attachments}} DROP PRIMARY KEY, ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE INDEX uk_att_uuid (uuid)" },
|
|
311
|
+
{ table: "project_members", sql: "ALTER TABLE {{project_members}} DROP PRIMARY KEY, ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE INDEX uk_pm (project_id, user_login)" },
|
|
312
|
+
];
|
|
313
|
+
for (const m of MYSQL_ALTERS) {
|
|
314
|
+
const tbl = DB_PREFIX + m.table;
|
|
315
|
+
const [cols] = await pool.query(
|
|
316
|
+
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'id'",
|
|
317
|
+
[tbl]
|
|
318
|
+
);
|
|
319
|
+
if ((cols as unknown[]).length > 0) continue;
|
|
320
|
+
try {
|
|
321
|
+
await pool.query(applyPrefix(m.sql));
|
|
322
|
+
} catch (e) {
|
|
323
|
+
const errno = (e as { errno?: number }).errno;
|
|
324
|
+
if (errno === 1146) continue;
|
|
325
|
+
console.warn(`[db] MySQL surrogate-id migration for ${m.table} failed (errno ${errno}):`, (e as Error).message);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const [cfgCols] = await pool.query(
|
|
329
|
+
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'key'",
|
|
330
|
+
[DB_PREFIX + "config"]
|
|
331
|
+
);
|
|
332
|
+
if ((cfgCols as unknown[]).length > 0) {
|
|
333
|
+
try {
|
|
334
|
+
await pool.query(applyPrefix("ALTER TABLE {{config}} CHANGE `key` akey VARCHAR(255) NOT NULL"));
|
|
335
|
+
} catch (e) {
|
|
336
|
+
console.warn("[db] MySQL config key→akey rename failed:", (e as Error).message);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
233
341
|
class MysqlDriver implements AsyncDatabase {
|
|
234
342
|
readonly dialect = "mysql" as const;
|
|
235
343
|
private readonly pool: Pool;
|
|
@@ -271,6 +379,7 @@ class MysqlDriver implements AsyncDatabase {
|
|
|
271
379
|
throw e;
|
|
272
380
|
}
|
|
273
381
|
}
|
|
382
|
+
await migrateMysqlSurrogateId(pool);
|
|
274
383
|
}
|
|
275
384
|
return new MysqlDriver(pool);
|
|
276
385
|
}
|
|
@@ -347,9 +456,9 @@ export function getDB(): AsyncDatabase {
|
|
|
347
456
|
export async function getConfigAll(): Promise<Record<string, string>> {
|
|
348
457
|
try {
|
|
349
458
|
const driver = getDB();
|
|
350
|
-
const rows = await driver.all<{
|
|
459
|
+
const rows = await driver.all<{ akey: string; value: string }>("SELECT akey, value FROM {{config}}");
|
|
351
460
|
const out: Record<string, string> = {};
|
|
352
|
-
for (const r of rows) out[r.
|
|
461
|
+
for (const r of rows) out[r.akey] = r.value;
|
|
353
462
|
return out;
|
|
354
463
|
} catch {
|
|
355
464
|
return {};
|
|
@@ -359,12 +468,12 @@ export async function getConfigAll(): Promise<Record<string, string>> {
|
|
|
359
468
|
export async function setConfig(key: string, value: string): Promise<void> {
|
|
360
469
|
const now = new Date().toISOString();
|
|
361
470
|
await getDB().run(
|
|
362
|
-
"INSERT INTO {{config}} (
|
|
363
|
-
"ON CONFLICT(
|
|
471
|
+
"INSERT INTO {{config}} (akey, value, updated_at) VALUES (?, ?, ?) " +
|
|
472
|
+
"ON CONFLICT(akey) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
|
364
473
|
[key, value, now]
|
|
365
474
|
);
|
|
366
475
|
}
|
|
367
476
|
|
|
368
477
|
export async function deleteConfig(key: string): Promise<void> {
|
|
369
|
-
await getDB().run("DELETE FROM {{config}} WHERE
|
|
478
|
+
await getDB().run("DELETE FROM {{config}} WHERE akey = ?", [key]);
|
|
370
479
|
}
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
archiveLabel,
|
|
69
69
|
deleteLabel,
|
|
70
70
|
setIssueLabels,
|
|
71
|
+
listAllProjectIds,
|
|
71
72
|
type ProjectRole,
|
|
72
73
|
type UserRow,
|
|
73
74
|
} from "./store";
|
|
@@ -127,6 +128,8 @@ async function refreshOpencodeClient(): Promise<void> {
|
|
|
127
128
|
await refreshOpencodeClient();
|
|
128
129
|
setInterval(refreshOpencodeClient, 10_000);
|
|
129
130
|
|
|
131
|
+
void autoWireAllProjects(`http://${cfg.host}:${cfg.port}`);
|
|
132
|
+
|
|
130
133
|
async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
|
|
131
134
|
if (!cfg.autowireActive) {
|
|
132
135
|
log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
|
|
@@ -160,6 +163,24 @@ async function autoWireDaemon(projectId: number, origin: string): Promise<void>
|
|
|
160
163
|
}
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
let backfillStarted = false;
|
|
167
|
+
async function autoWireAllProjects(origin: string): Promise<void> {
|
|
168
|
+
if (backfillStarted) return;
|
|
169
|
+
backfillStarted = true;
|
|
170
|
+
if (!cfg.autowireActive || (!cfg.daemonBotLogin.trim() && !cfg.daemonWebhookUrl.trim())) return;
|
|
171
|
+
try {
|
|
172
|
+
const ids = await listAllProjectIds();
|
|
173
|
+
let wired = 0;
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
await autoWireDaemon(id, origin);
|
|
176
|
+
wired++;
|
|
177
|
+
}
|
|
178
|
+
if (wired > 0) log.info("autoWireAllProjects: backfilled", { count: wired });
|
|
179
|
+
} catch (e) {
|
|
180
|
+
log.warn("autoWireAllProjects failed", { err: e as Error });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
163
184
|
const SEC_HEADERS: Record<string, string> = {
|
|
164
185
|
"content-security-policy": `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
|
|
165
186
|
"x-content-type-options": "nosniff",
|
|
@@ -561,7 +582,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
561
582
|
const form = await req.formData().catch(() => new FormData());
|
|
562
583
|
const f: Record<string, string | undefined> = {};
|
|
563
584
|
for (const [k, v] of form.entries()) f[k] = typeof v === "string" ? v : undefined;
|
|
564
|
-
const r = await handleCreateProject(f);
|
|
585
|
+
const r = await handleCreateProject(f, cfg.defaultModel);
|
|
565
586
|
if (r.projectId) {
|
|
566
587
|
await ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
|
|
567
588
|
await autoWireDaemon(r.projectId, url.origin);
|
|
@@ -973,6 +994,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
973
994
|
return json({ ok: true });
|
|
974
995
|
}
|
|
975
996
|
|
|
997
|
+
if (req.method === "POST" && url.pathname === "/api/daemons/wire-all") {
|
|
998
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
999
|
+
if (!rateLimit(`daemons-wire:${ip}`, 5, 5 / 3600)) return json({ error: "rate limited" }, 429);
|
|
1000
|
+
try {
|
|
1001
|
+
const ids = await listAllProjectIds();
|
|
1002
|
+
for (const id of ids) await autoWireDaemon(id, url.origin);
|
|
1003
|
+
return json({ ok: true, count: ids.length });
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
return json({ ok: false, error: errMsg(e) });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
976
1009
|
if (req.method === "POST" && url.pathname === "/api/daemons/deploy") {
|
|
977
1010
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
978
1011
|
if (!rateLimit(`daemons-deploy:${ip}`, 10, 10 / 3600)) return json({ error: "rate limited" }, 429);
|
|
@@ -1931,10 +1964,7 @@ function parseState(s: string | null): "open" | "closed" | "all" {
|
|
|
1931
1964
|
async function createProjectSafe(owner: string, name: string) {
|
|
1932
1965
|
let project = await getProject(owner, name);
|
|
1933
1966
|
if (project) return project;
|
|
1934
|
-
|
|
1935
|
-
// URL regex shape; allow creation here so `/<owner>/<new-repo>/issues` (POST) bootstraps
|
|
1936
|
-
// a project in one step. Use createIssue's tx for atomicity.
|
|
1937
|
-
project = await createProject(owner, name, "");
|
|
1967
|
+
project = await createProject(owner, name, "", cfg.defaultModel);
|
|
1938
1968
|
return project;
|
|
1939
1969
|
}
|
|
1940
1970
|
|
package/src/schema-mysql.sql
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
-- tables InnoDB + utf8mb4 for FK CASCADE + full Unicode (emoji).
|
|
9
9
|
|
|
10
10
|
CREATE TABLE IF NOT EXISTS {{users}} (
|
|
11
|
-
|
|
11
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
12
|
+
login VARCHAR(255) NOT NULL UNIQUE,
|
|
12
13
|
kind VARCHAR(16) NOT NULL DEFAULT 'human'
|
|
13
14
|
CHECK (kind IN ('human','bot','system')),
|
|
14
15
|
display_name VARCHAR(255) DEFAULT NULL,
|
|
@@ -33,7 +34,8 @@ CREATE TABLE IF NOT EXISTS {{projects}} (
|
|
|
33
34
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
34
35
|
|
|
35
36
|
CREATE TABLE IF NOT EXISTS {{model_cache}} (
|
|
36
|
-
|
|
37
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
38
|
+
provider_model VARCHAR(128) NOT NULL UNIQUE,
|
|
37
39
|
label VARCHAR(255) NOT NULL,
|
|
38
40
|
refreshed_at VARCHAR(40) NOT NULL
|
|
39
41
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
@@ -86,19 +88,21 @@ CREATE TABLE IF NOT EXISTS {{labels}} (
|
|
|
86
88
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
87
89
|
|
|
88
90
|
CREATE TABLE IF NOT EXISTS {{issue_labels}} (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
92
|
+
issue_id BIGINT NOT NULL,
|
|
93
|
+
label_id BIGINT NOT NULL,
|
|
94
|
+
UNIQUE (issue_id, label_id),
|
|
92
95
|
CONSTRAINT {{fk_il_issue}} FOREIGN KEY (issue_id) REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
93
96
|
CONSTRAINT {{fk_il_label}} FOREIGN KEY (label_id) REFERENCES {{labels}}(id) ON DELETE CASCADE
|
|
94
97
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
95
98
|
CREATE INDEX issue_labels_label ON {{issue_labels}} (label_id);
|
|
96
99
|
|
|
97
100
|
CREATE TABLE IF NOT EXISTS {{reactions}} (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
102
|
+
comment_id BIGINT NOT NULL,
|
|
103
|
+
user_login VARCHAR(255) NOT NULL,
|
|
104
|
+
content VARCHAR(64) NOT NULL,
|
|
105
|
+
UNIQUE (comment_id, user_login, content),
|
|
102
106
|
CONSTRAINT {{fk_reactions_comment}} FOREIGN KEY (comment_id) REFERENCES {{comments}}(id) ON DELETE CASCADE,
|
|
103
107
|
CONSTRAINT {{fk_reactions_user}} FOREIGN KEY (user_login) REFERENCES {{users}}(login)
|
|
104
108
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
@@ -106,14 +110,15 @@ CREATE INDEX reactions_comment ON {{reactions}} (comment_id);
|
|
|
106
110
|
CREATE INDEX reactions_user ON {{reactions}} (user_login);
|
|
107
111
|
|
|
108
112
|
CREATE TABLE IF NOT EXISTS {{attachments}} (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
114
|
+
uuid VARCHAR(64) NOT NULL UNIQUE,
|
|
115
|
+
issue_id BIGINT NOT NULL,
|
|
116
|
+
filename VARCHAR(255) NOT NULL,
|
|
117
|
+
content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
|
|
118
|
+
size BIGINT NOT NULL,
|
|
119
|
+
blob_path VARCHAR(1024) NOT NULL,
|
|
120
|
+
uploaded_by VARCHAR(255) NOT NULL,
|
|
121
|
+
created_at VARCHAR(40) NOT NULL,
|
|
117
122
|
CONSTRAINT {{fk_attachments_issue}} FOREIGN KEY (issue_id) REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
118
123
|
CONSTRAINT {{fk_attachments_user}} FOREIGN KEY (uploaded_by) REFERENCES {{users}}(login)
|
|
119
124
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
@@ -169,12 +174,13 @@ CREATE INDEX pat_user ON {{personal_access_tokens}} (user_login);
|
|
|
169
174
|
CREATE INDEX pat_last_eight ON {{personal_access_tokens}} (token_last_eight);
|
|
170
175
|
|
|
171
176
|
CREATE TABLE IF NOT EXISTS {{project_members}} (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
178
|
+
project_id BIGINT NOT NULL,
|
|
179
|
+
user_login VARCHAR(255) NOT NULL,
|
|
180
|
+
role VARCHAR(16) NOT NULL DEFAULT 'writer'
|
|
181
|
+
CHECK (role IN ('reader','writer','admin')),
|
|
182
|
+
created_at VARCHAR(40) NOT NULL,
|
|
183
|
+
UNIQUE (project_id, user_login),
|
|
178
184
|
CONSTRAINT {{fk_pm_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE,
|
|
179
185
|
CONSTRAINT {{fk_pm_user}} FOREIGN KEY (user_login) REFERENCES {{users}}(login) ON DELETE CASCADE
|
|
180
186
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
package/src/schema.sql
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
-- ework schema. Applied idempotently on boot (IF NOT EXISTS everywhere).
|
|
2
2
|
-- See db.ts for PRAGMA setup (WAL + foreign_keys = ON).
|
|
3
3
|
|
|
4
|
-
-- login
|
|
5
|
-
-- swap to INTEGER user_id + UNIQUE(login) if that ever changes.
|
|
4
|
+
-- login is UNIQUE (not PRIMARY KEY) — surrogate id is the PK now.
|
|
6
5
|
CREATE TABLE IF NOT EXISTS {{users}} (
|
|
7
|
-
|
|
6
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
login TEXT NOT NULL UNIQUE,
|
|
8
8
|
kind TEXT NOT NULL DEFAULT 'human'
|
|
9
9
|
CHECK (kind IN ('human','bot','system')),
|
|
10
10
|
display_name TEXT,
|
|
@@ -40,7 +40,8 @@ CREATE TABLE IF NOT EXISTS {{projects}} (
|
|
|
40
40
|
-- than a single JSON blob so the settings UI can render a select without
|
|
41
41
|
-- parsing JSON in SQL.
|
|
42
42
|
CREATE TABLE IF NOT EXISTS {{model_cache}} (
|
|
43
|
-
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
provider_model TEXT NOT NULL UNIQUE,
|
|
44
45
|
label TEXT NOT NULL,
|
|
45
46
|
refreshed_at TEXT NOT NULL
|
|
46
47
|
);
|
|
@@ -89,29 +90,32 @@ CREATE TABLE IF NOT EXISTS {{labels}} (
|
|
|
89
90
|
);
|
|
90
91
|
|
|
91
92
|
CREATE TABLE IF NOT EXISTS {{issue_labels}} (
|
|
93
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
94
|
issue_id INTEGER NOT NULL REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
93
95
|
label_id INTEGER NOT NULL REFERENCES {{labels}}(id) ON DELETE CASCADE,
|
|
94
|
-
|
|
96
|
+
UNIQUE (issue_id, label_id)
|
|
95
97
|
);
|
|
96
98
|
|
|
97
99
|
CREATE TABLE IF NOT EXISTS {{reactions}} (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
101
|
+
comment_id INTEGER NOT NULL REFERENCES {{comments}}(id) ON DELETE CASCADE,
|
|
102
|
+
user_login TEXT NOT NULL REFERENCES {{users}}(login),
|
|
103
|
+
content TEXT NOT NULL,
|
|
104
|
+
UNIQUE (comment_id, user_login, content)
|
|
102
105
|
);
|
|
103
106
|
CREATE INDEX IF NOT EXISTS reactions_comment
|
|
104
107
|
ON {{reactions}} (comment_id);
|
|
105
108
|
|
|
106
109
|
CREATE TABLE IF NOT EXISTS {{attachments}} (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
110
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
111
|
+
uuid TEXT NOT NULL UNIQUE,
|
|
112
|
+
issue_id INTEGER NOT NULL REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
113
|
+
filename TEXT NOT NULL,
|
|
114
|
+
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
115
|
+
size INTEGER NOT NULL,
|
|
116
|
+
blob_path TEXT NOT NULL,
|
|
117
|
+
uploaded_by TEXT NOT NULL REFERENCES {{users}}(login),
|
|
118
|
+
created_at TEXT NOT NULL
|
|
115
119
|
);
|
|
116
120
|
CREATE INDEX IF NOT EXISTS attachments_issue
|
|
117
121
|
ON {{attachments}} (issue_id);
|
|
@@ -187,12 +191,13 @@ CREATE INDEX IF NOT EXISTS pat_last_eight
|
|
|
187
191
|
-- routes through here: a write-scoped PAT can only write where the owning user
|
|
188
192
|
-- has writer+ role on the target project.
|
|
189
193
|
CREATE TABLE IF NOT EXISTS {{project_members}} (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
195
|
+
project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
|
|
196
|
+
user_login TEXT NOT NULL REFERENCES {{users}}(login) ON DELETE CASCADE,
|
|
197
|
+
role TEXT NOT NULL DEFAULT 'writer'
|
|
198
|
+
CHECK (role IN ('reader','writer','admin')),
|
|
199
|
+
created_at TEXT NOT NULL,
|
|
200
|
+
UNIQUE (project_id, user_login)
|
|
196
201
|
);
|
|
197
202
|
CREATE INDEX IF NOT EXISTS project_members_user
|
|
198
203
|
ON {{project_members}} (user_login);
|
package/src/store.ts
CHANGED
|
@@ -171,7 +171,12 @@ export async function listProjectsWithCounts(): Promise<ProjectWithCounts[]> {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
export async function
|
|
174
|
+
export async function listAllProjectIds(): Promise<number[]> {
|
|
175
|
+
const rows = await getDB().all<{ id: number }>("SELECT id FROM {{projects}} ORDER BY id");
|
|
176
|
+
return rows.map((r) => r.id);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function createProject(owner: string, name: string, description: string, model?: string): Promise<ProjectRow> {
|
|
175
180
|
owner = owner.trim();
|
|
176
181
|
name = name.trim();
|
|
177
182
|
if (!/^[A-Za-z0-9_.-]+$/.test(owner)) throw new StoreError(400, "owner 含非法字符");
|
|
@@ -180,8 +185,8 @@ export async function createProject(owner: string, name: string, description: st
|
|
|
180
185
|
const ts = now();
|
|
181
186
|
const db = getDB();
|
|
182
187
|
const info = await db.run(
|
|
183
|
-
"INSERT INTO {{projects}} (owner, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
184
|
-
[owner, name, description ?? "", ts, ts]
|
|
188
|
+
"INSERT INTO {{projects}} (owner, name, description, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
189
|
+
[owner, name, description ?? "", model ?? "", ts, ts]
|
|
185
190
|
);
|
|
186
191
|
return (await getProjectById(info.insertId))!;
|
|
187
192
|
}
|
|
@@ -689,33 +694,41 @@ export async function setIssueLabel(issueId: number, labelId: number, on: boolea
|
|
|
689
694
|
}
|
|
690
695
|
const label = await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [labelId]);
|
|
691
696
|
if (!label) throw new StoreError(404, "标签不存在");
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
697
|
+
// Wrap read-modify-write in a transaction to prevent concurrent callers
|
|
698
|
+
// from racing on exclusive-scope eviction (read old set → both insert).
|
|
699
|
+
await getDB().transaction(async () => {
|
|
700
|
+
if (label.exclusive === 1) {
|
|
701
|
+
const scope = labelScope(label.name);
|
|
702
|
+
if (scope) {
|
|
703
|
+
const current = await listLabelsForIssue(issueId);
|
|
704
|
+
const siblings = current.filter((l) => l.exclusive === 1 && labelScope(l.name) === scope && l.id !== labelId);
|
|
705
|
+
for (const s of siblings) {
|
|
706
|
+
await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, s.id]);
|
|
707
|
+
}
|
|
699
708
|
}
|
|
700
709
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
710
|
+
const dialect = getDB().dialect;
|
|
711
|
+
const insert = dialect === "sqlite"
|
|
712
|
+
? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
|
|
713
|
+
: "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
|
|
714
|
+
await getDB().run(insert, [issueId, labelId]);
|
|
715
|
+
});
|
|
707
716
|
}
|
|
708
717
|
|
|
709
718
|
export async function setIssueLabels(issueId: number, labelIds: number[]): Promise<void> {
|
|
710
719
|
const dialect = getDB().dialect;
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
720
|
+
// Wrap DELETE + INSERTs in a transaction so a crash between operations
|
|
721
|
+
// doesn't leave the issue with zero labels.
|
|
722
|
+
await getDB().transaction(async () => {
|
|
723
|
+
await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ?", [issueId]);
|
|
724
|
+
const ids = Array.from(new Set(labelIds));
|
|
725
|
+
const insert = dialect === "sqlite"
|
|
726
|
+
? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
|
|
727
|
+
: "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
|
|
728
|
+
for (const id of ids) {
|
|
729
|
+
await getDB().run(insert, [issueId, id]);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
719
732
|
}
|
|
720
733
|
|
|
721
734
|
export async function createAttachment(a: Omit<AttachmentRow, "created_at">): Promise<AttachmentRow> {
|
package/src/views/home.ts
CHANGED
|
@@ -92,14 +92,15 @@ ${tabNavHTML("projects")}
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export async function handleCreateProject(
|
|
95
|
-
form: Record<string, string | undefined
|
|
95
|
+
form: Record<string, string | undefined>,
|
|
96
|
+
defaultModel: string,
|
|
96
97
|
): Promise<{ location: string; error?: string; projectId?: number }> {
|
|
97
98
|
const owner = (form.owner ?? "").trim();
|
|
98
99
|
const name = (form.name ?? "").trim();
|
|
99
100
|
const description = (form.description ?? "").trim();
|
|
100
101
|
if (!owner || !name) return { location: "/projects", error: "owner 和 name 必填" };
|
|
101
102
|
try {
|
|
102
|
-
const p = await createProject(owner, name, description);
|
|
103
|
+
const p = await createProject(owner, name, description, defaultModel);
|
|
103
104
|
return { location: `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/issues`, projectId: p.id };
|
|
104
105
|
} catch (e) {
|
|
105
106
|
return {
|