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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.19",
3
+ "version": "0.10.21",
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
@@ -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. `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.
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
- "`key` VARCHAR(255) PRIMARY KEY," +
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:144 — not in schema.sql).
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}} (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)",
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. `key` is a
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
- "`key` VARCHAR(255) PRIMARY KEY," +
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}} (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)"
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<{ key: string; value: string }>("SELECT key, value FROM {{config}}");
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.key] = r.value;
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}} (key, value, updated_at) VALUES (?, ?, ?) " +
363
- "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
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 key = ?", [key]);
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
- // Auto-create project on first issue POST. Owner/name were already validated by the
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
 
@@ -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
- login VARCHAR(255) PRIMARY KEY,
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
- provider_model VARCHAR(128) PRIMARY KEY,
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
- issue_id BIGINT NOT NULL,
90
- label_id BIGINT NOT NULL,
91
- PRIMARY KEY (issue_id, label_id),
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
- comment_id BIGINT NOT NULL,
99
- user_login VARCHAR(255) NOT NULL,
100
- content VARCHAR(64) NOT NULL,
101
- PRIMARY KEY (comment_id, user_login, content),
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
- uuid VARCHAR(64) PRIMARY KEY,
110
- issue_id BIGINT NOT NULL,
111
- filename VARCHAR(255) NOT NULL,
112
- content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
113
- size BIGINT NOT NULL,
114
- blob_path VARCHAR(1024) NOT NULL,
115
- uploaded_by VARCHAR(255) NOT NULL,
116
- created_at VARCHAR(40) NOT NULL,
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
- project_id BIGINT NOT NULL,
173
- user_login VARCHAR(255) NOT NULL,
174
- role VARCHAR(16) NOT NULL DEFAULT 'writer'
175
- CHECK (role IN ('reader','writer','admin')),
176
- created_at VARCHAR(40) NOT NULL,
177
- PRIMARY KEY (project_id, user_login),
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 stays PRIMARY KEY intentionally. Renaming users is not supported;
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
- login TEXT PRIMARY KEY,
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
- provider_model TEXT PRIMARY KEY,
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
- PRIMARY KEY (issue_id, label_id)
96
+ UNIQUE (issue_id, label_id)
95
97
  );
96
98
 
97
99
  CREATE TABLE IF NOT EXISTS {{reactions}} (
98
- comment_id INTEGER NOT NULL REFERENCES {{comments}}(id) ON DELETE CASCADE,
99
- user_login TEXT NOT NULL REFERENCES {{users}}(login),
100
- content TEXT NOT NULL,
101
- PRIMARY KEY (comment_id, user_login, content)
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
- uuid TEXT PRIMARY KEY,
108
- issue_id INTEGER NOT NULL REFERENCES {{issues}}(id) ON DELETE CASCADE,
109
- filename TEXT NOT NULL,
110
- content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
111
- size INTEGER NOT NULL,
112
- blob_path TEXT NOT NULL,
113
- uploaded_by TEXT NOT NULL REFERENCES {{users}}(login),
114
- created_at TEXT NOT NULL
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
- project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
191
- user_login TEXT NOT NULL REFERENCES {{users}}(login) ON DELETE CASCADE,
192
- role TEXT NOT NULL DEFAULT 'writer'
193
- CHECK (role IN ('reader','writer','admin')),
194
- created_at TEXT NOT NULL,
195
- PRIMARY KEY (project_id, user_login)
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 createProject(owner: string, name: string, description: string): Promise<ProjectRow> {
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
- if (label.exclusive === 1) {
693
- const scope = labelScope(label.name);
694
- if (scope) {
695
- const current = await listLabelsForIssue(issueId);
696
- const siblings = current.filter((l) => l.exclusive === 1 && labelScope(l.name) === scope && l.id !== labelId);
697
- for (const s of siblings) {
698
- await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, s.id]);
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
- const dialect = getDB().dialect;
703
- const insert = dialect === "sqlite"
704
- ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
705
- : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
706
- await getDB().run(insert, [issueId, labelId]);
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
- await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ?", [issueId]);
712
- const ids = Array.from(new Set(labelIds));
713
- const insert = dialect === "sqlite"
714
- ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
715
- : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
716
- for (const id of ids) {
717
- await getDB().run(insert, [issueId, id]);
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 {