ework-web 0.10.18 → 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 +1 -1
- package/src/db-admin.ts +8 -10
- package/src/db.ts +115 -6
- package/src/index.ts +32 -32
- package/src/schema-mysql.sql +29 -23
- package/src/schema.sql +28 -23
- package/src/store.ts +29 -21
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
|
@@ -1386,6 +1386,38 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1386
1386
|
}
|
|
1387
1387
|
}
|
|
1388
1388
|
|
|
1389
|
+
const issueLabelsApi = url.pathname.match(API_ISSUE_LABELS_RE);
|
|
1390
|
+
if (issueLabelsApi) {
|
|
1391
|
+
const [, owner, repo, numStr] = issueLabelsApi;
|
|
1392
|
+
if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
|
|
1393
|
+
const project = await getProject(owner, repo);
|
|
1394
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
1395
|
+
const issue = await getIssueWithMeta(project.id, Number(numStr));
|
|
1396
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1397
|
+
if (req.method === "GET") {
|
|
1398
|
+
const [current, available] = await Promise.all([
|
|
1399
|
+
listLabelsForIssue(issue.id),
|
|
1400
|
+
listLabels(project.id),
|
|
1401
|
+
]);
|
|
1402
|
+
return json({ current, available });
|
|
1403
|
+
}
|
|
1404
|
+
if (req.method === "POST") {
|
|
1405
|
+
if (!ctx.user || !(await canWriteProject(project.id, ctx.user))) {
|
|
1406
|
+
return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
1407
|
+
}
|
|
1408
|
+
const body = await req.json().catch(() => ({}));
|
|
1409
|
+
const labelIds = Array.isArray(body.labelIds) ? body.labelIds.map((n: unknown) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0) : [];
|
|
1410
|
+
try {
|
|
1411
|
+
await setIssueLabels(issue.id, labelIds);
|
|
1412
|
+
const current = await listLabelsForIssue(issue.id);
|
|
1413
|
+
return json({ current });
|
|
1414
|
+
} catch (e) {
|
|
1415
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return json({ error: "method not allowed" }, 405);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1389
1421
|
if (req.method === "POST") {
|
|
1390
1422
|
if (!cfg.writesEnabled) return json({ error: "writes disabled" }, 403);
|
|
1391
1423
|
const up = url.pathname.match(UPLOAD_RE);
|
|
@@ -1765,38 +1797,6 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1765
1797
|
}
|
|
1766
1798
|
}
|
|
1767
1799
|
|
|
1768
|
-
const issueLabelsApi = url.pathname.match(API_ISSUE_LABELS_RE);
|
|
1769
|
-
if (issueLabelsApi) {
|
|
1770
|
-
const [, owner, repo, numStr] = issueLabelsApi;
|
|
1771
|
-
if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
|
|
1772
|
-
const project = await getProject(owner, repo);
|
|
1773
|
-
if (!project) return json({ error: "project not found" }, 404);
|
|
1774
|
-
const issue = await getIssueWithMeta(project.id, Number(numStr));
|
|
1775
|
-
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1776
|
-
if (req.method === "GET") {
|
|
1777
|
-
const [current, available] = await Promise.all([
|
|
1778
|
-
listLabelsForIssue(issue.id),
|
|
1779
|
-
listLabels(project.id),
|
|
1780
|
-
]);
|
|
1781
|
-
return json({ current, available });
|
|
1782
|
-
}
|
|
1783
|
-
if (req.method === "POST") {
|
|
1784
|
-
if (!ctx.user || !(await canWriteProject(project.id, ctx.user))) {
|
|
1785
|
-
return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
1786
|
-
}
|
|
1787
|
-
const body = await req.json().catch(() => ({}));
|
|
1788
|
-
const labelIds = Array.isArray(body.labelIds) ? body.labelIds.map((n: unknown) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0) : [];
|
|
1789
|
-
try {
|
|
1790
|
-
await setIssueLabels(issue.id, labelIds);
|
|
1791
|
-
const current = await listLabelsForIssue(issue.id);
|
|
1792
|
-
return json({ current });
|
|
1793
|
-
} catch (e) {
|
|
1794
|
-
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
return json({ error: "method not allowed" }, 405);
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
1800
|
const isNew = url.pathname.match(REPO_NEW_RE);
|
|
1801
1801
|
if (isNew) {
|
|
1802
1802
|
const [, owner, repo] = isNew;
|
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
|
@@ -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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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> {
|