ework-web 0.10.19 → 0.10.20

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.20",
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
  }
@@ -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
@@ -689,33 +689,41 @@ export async function setIssueLabel(issueId: number, labelId: number, on: boolea
689
689
  }
690
690
  const label = await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [labelId]);
691
691
  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]);
692
+ // Wrap read-modify-write in a transaction to prevent concurrent callers
693
+ // from racing on exclusive-scope eviction (read old set → both insert).
694
+ await getDB().transaction(async () => {
695
+ if (label.exclusive === 1) {
696
+ const scope = labelScope(label.name);
697
+ if (scope) {
698
+ const current = await listLabelsForIssue(issueId);
699
+ const siblings = current.filter((l) => l.exclusive === 1 && labelScope(l.name) === scope && l.id !== labelId);
700
+ for (const s of siblings) {
701
+ await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, s.id]);
702
+ }
699
703
  }
700
704
  }
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]);
705
+ const dialect = getDB().dialect;
706
+ const insert = dialect === "sqlite"
707
+ ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
708
+ : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
709
+ await getDB().run(insert, [issueId, labelId]);
710
+ });
707
711
  }
708
712
 
709
713
  export async function setIssueLabels(issueId: number, labelIds: number[]): Promise<void> {
710
714
  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
- }
715
+ // Wrap DELETE + INSERTs in a transaction so a crash between operations
716
+ // doesn't leave the issue with zero labels.
717
+ await getDB().transaction(async () => {
718
+ await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ?", [issueId]);
719
+ const ids = Array.from(new Set(labelIds));
720
+ const insert = dialect === "sqlite"
721
+ ? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
722
+ : "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
723
+ for (const id of ids) {
724
+ await getDB().run(insert, [issueId, id]);
725
+ }
726
+ });
719
727
  }
720
728
 
721
729
  export async function createAttachment(a: Omit<AttachmentRow, "created_at">): Promise<AttachmentRow> {