db-player 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,827 @@
1
+ import { z } from "zod";
2
+ import { createClient } from "@clickhouse/client";
3
+ import mysql from "mysql2/promise";
4
+ import mysqlCore from "mysql2";
5
+ import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ // src/lib/types.ts
9
+ var DB_KINDS = ["mysql", "clickhouse"];
10
+ var DB_KIND_INFO = {
11
+ mysql: {
12
+ label: "MySQL",
13
+ defaultPort: 3306
14
+ },
15
+ clickhouse: {
16
+ label: "ClickHouse",
17
+ defaultPort: 8123
18
+ }
19
+ };
20
+ var FOLDER_COLORS = {
21
+ gray: "#8a8a8a",
22
+ red: "#ef4444",
23
+ orange: "#f97316",
24
+ yellow: "#eab308",
25
+ green: "#22c55e",
26
+ teal: "#14b8a6",
27
+ blue: "#3b82f6",
28
+ purple: "#a855f7"
29
+ };
30
+ var FOLDER_COLOR_NAMES = Object.keys(FOLDER_COLORS);
31
+ var folderInputSchema = z.object({
32
+ name: z.string().trim().min(1, "Name is required").max(60),
33
+ color: z.enum(FOLDER_COLOR_NAMES)
34
+ });
35
+ var connectionInputSchema = z.object({
36
+ folderId: z.string().nullable(),
37
+ name: z.string().trim().min(1, "Name is required").max(80),
38
+ kind: z.enum(DB_KINDS),
39
+ host: z.string().trim().min(1, "Host is required"),
40
+ port: z.number().int().min(1).max(65535),
41
+ user: z.string().trim().min(1, "User is required"),
42
+ password: z.string(),
43
+ database: z.string().trim().nullable(),
44
+ secure: z.boolean()
45
+ });
46
+ var tabIdSchema = z.custom((v) => typeof v === "string" && v.length > 0);
47
+ var newTabTargetSchema = z.discriminatedUnion("kind", [z.object({
48
+ kind: z.literal("sql"),
49
+ connectionId: z.string().min(1),
50
+ database: z.string().min(1).nullable()
51
+ }), z.object({
52
+ kind: z.literal("table"),
53
+ connectionId: z.string().min(1),
54
+ database: z.string().min(1),
55
+ table: z.string().min(1)
56
+ })]);
57
+ // src/server/drivers/shared.ts
58
+ function normalizeCell(value) {
59
+ if (value === null || value === void 0) return null;
60
+ if (Buffer.isBuffer(value)) return `0x${value.toString("hex")}`;
61
+ if (typeof value === "bigint") return value.toString();
62
+ if (value instanceof Date) return value.toISOString();
63
+ if (typeof value === "object") return JSON.parse(JSON.stringify(value));
64
+ return value;
65
+ }
66
+ function fingerprint(parts) {
67
+ return JSON.stringify(parts);
68
+ }
69
+ var SQL_SCHEMA_ROW_CAP = 3e4;
70
+ /** Database and table names are user data, so they key prototype-free objects. */
71
+ function groupColumns(rows) {
72
+ const schema = Object.create(null);
73
+ for (const r of rows) {
74
+ const tables = schema[String(r.db)] ??= Object.create(null);
75
+ (tables[String(r.tbl)] ??= []).push({
76
+ name: String(r.col),
77
+ type: String(r.type)
78
+ });
79
+ }
80
+ return schema;
81
+ }
82
+ // src/server/drivers/clickhouse.ts
83
+ /** One client per database, because the HTTP interface picks the database per client. */
84
+ var clients = /* @__PURE__ */ new Map();
85
+ function getClient(conn, database = conn.database) {
86
+ const key = fingerprint([
87
+ conn.host,
88
+ conn.port,
89
+ conn.user,
90
+ conn.password,
91
+ conn.database,
92
+ conn.secure
93
+ ]);
94
+ let entry = clients.get(conn.id);
95
+ if (entry?.key !== key) {
96
+ closeAll(entry);
97
+ entry = {
98
+ key,
99
+ byDatabase: /* @__PURE__ */ new Map()
100
+ };
101
+ clients.set(conn.id, entry);
102
+ }
103
+ const slot = database || "";
104
+ const cached = entry.byDatabase.get(slot);
105
+ if (cached) return cached;
106
+ const client = createClient({
107
+ url: `${conn.secure ? "https" : "http"}://${conn.host}:${conn.port}`,
108
+ username: conn.user,
109
+ password: conn.password,
110
+ database: database || void 0,
111
+ request_timeout: 3e4
112
+ });
113
+ entry.byDatabase.set(slot, client);
114
+ return client;
115
+ }
116
+ async function closeAll(entry) {
117
+ await Promise.all([...entry?.byDatabase.values() ?? []].map((client) => client.close().catch(() => {})));
118
+ }
119
+ var quote$1 = (name) => `\`${name.replaceAll("\\", "\\\\").replaceAll("`", "\\`")}\``;
120
+ var qualified$1 = (ref) => `${quote$1(ref.database)}.${quote$1(ref.table)}`;
121
+ var RETURNS_ROWS = /^[\s(]*(select|with|show|describe|desc|explain|exists|values)\b/i;
122
+ async function fetchRows(client, query, query_params, settings) {
123
+ return await (await client.query({
124
+ query,
125
+ query_params,
126
+ format: "JSONCompact",
127
+ clickhouse_settings: settings
128
+ })).json();
129
+ }
130
+ async function objects(client, query, params) {
131
+ const { meta, data } = await fetchRows(client, query, params);
132
+ return data.map((row) => Object.fromEntries(row.map((v, i) => [meta[i]?.name ?? String(i), v])));
133
+ }
134
+ /** Parameter types cannot carry LowCardinality. */
135
+ var paramType = (type) => type.replace(/LowCardinality\((.*)\)/, "$1");
136
+ var Params = class {
137
+ n = 0;
138
+ values = {};
139
+ bind(value, type) {
140
+ const name = `p${this.n++}`;
141
+ this.values[name] = typeof value === "object" ? JSON.stringify(value) : value;
142
+ return `{${name}:${paramType(type)}}`;
143
+ }
144
+ };
145
+ async function columnTypes(client, ref) {
146
+ const rows = await objects(client, "SELECT name, type FROM system.columns WHERE database = {db:String} AND table = {t:String}", {
147
+ db: ref.database,
148
+ t: ref.table
149
+ });
150
+ return new Map(rows.map((r) => [String(r.name), String(r.type)]));
151
+ }
152
+ function condition(key, types, params) {
153
+ return Object.entries(key).map(([col, value]) => value === null ? `isNull(${quote$1(col)})` : `${quote$1(col)} = ${params.bind(value, types.get(col) ?? "String")}`).join(" AND ");
154
+ }
155
+ var MUTATION_SETTINGS = { mutations_sync: "2" };
156
+ /** Level 2 refuses writes and DDL but still lets the client pass its own settings. */
157
+ var READ_ONLY_SETTINGS = { readonly: "2" };
158
+ var clickhouseDriver = {
159
+ async test(conn) {
160
+ const rows = await objects(getClient(conn), "SELECT version() AS v");
161
+ return { version: String(rows[0]?.v) };
162
+ },
163
+ async listDatabases(conn) {
164
+ return (await objects(getClient(conn), "SELECT name FROM system.databases ORDER BY name")).map((r) => String(r.name));
165
+ },
166
+ async listTables(conn, database) {
167
+ return (await objects(getClient(conn), "SELECT name, engine FROM system.tables WHERE database = {db:String} ORDER BY name", { db: database })).map((r) => ({
168
+ name: String(r.name),
169
+ isView: String(r.engine).endsWith("View")
170
+ }));
171
+ },
172
+ async describeTable(conn, ref) {
173
+ const client = getClient(conn);
174
+ const [cols, tables] = await Promise.all([objects(client, `SELECT name, type, default_expression, is_in_primary_key, is_in_sorting_key
175
+ FROM system.columns WHERE database = {db:String} AND table = {t:String} ORDER BY position`, {
176
+ db: ref.database,
177
+ t: ref.table
178
+ }), objects(client, "SELECT engine FROM system.tables WHERE database = {db:String} AND name = {t:String}", {
179
+ db: ref.database,
180
+ t: ref.table
181
+ })]);
182
+ const columns = cols.map((r) => ({
183
+ name: String(r.name),
184
+ type: String(r.type),
185
+ nullable: /Nullable\(/.test(String(r.type)),
186
+ isPrimaryKey: Number(r.is_in_primary_key) === 1 || Number(r.is_in_sorting_key) === 1,
187
+ default: r.default_expression ? String(r.default_expression) : null
188
+ }));
189
+ const keyColumns = columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
190
+ const engine = String(tables[0]?.engine ?? "");
191
+ return {
192
+ columns,
193
+ keyColumns,
194
+ editBlockedReason: !engine.endsWith("MergeTree") ? `Engine ${engine || "unknown"} does not support row mutations. Editing is disabled.` : keyColumns.length === 0 ? "This table has no sorting key (ORDER BY tuple()), so rows cannot be addressed safely. Editing is disabled." : null
195
+ };
196
+ },
197
+ async sqlSchema(conn) {
198
+ const defaultDatabase = conn.database || "default";
199
+ return {
200
+ defaultDatabase,
201
+ schema: groupColumns(await objects(getClient(conn), `SELECT database AS db, table AS tbl, name AS col, type
202
+ FROM system.columns
203
+ WHERE database NOT IN ('system', 'INFORMATION_SCHEMA', 'information_schema')
204
+ OR database = {def:String}
205
+ ORDER BY database = {def:String} DESC, database, table, position
206
+ LIMIT ${SQL_SCHEMA_ROW_CAP}`, { def: defaultDatabase }))
207
+ };
208
+ },
209
+ async query(conn, sql, { database, limit, readOnly }) {
210
+ const client = getClient(conn, database ?? conn.database);
211
+ const text = sql.trim().replace(/;+\s*$/, "");
212
+ const started = performance.now();
213
+ if (!RETURNS_ROWS.test(text)) {
214
+ await client.command({
215
+ query: text,
216
+ clickhouse_settings: readOnly ? READ_ONLY_SETTINGS : MUTATION_SETTINGS
217
+ });
218
+ return {
219
+ columns: [],
220
+ rows: [],
221
+ rowCount: 0,
222
+ elapsedMs: Math.round(performance.now() - started),
223
+ truncated: false
224
+ };
225
+ }
226
+ const { meta, data } = await fetchRows(client, text, void 0, {
227
+ ...readOnly ? READ_ONLY_SETTINGS : {},
228
+ max_result_rows: String(limit + 1),
229
+ result_overflow_mode: "break"
230
+ });
231
+ return {
232
+ columns: meta,
233
+ rows: data.slice(0, limit),
234
+ rowCount: Math.min(data.length, limit),
235
+ elapsedMs: Math.round(performance.now() - started),
236
+ truncated: data.length > limit
237
+ };
238
+ },
239
+ async selectRows(conn, ref, opts) {
240
+ const client = getClient(conn);
241
+ const started = performance.now();
242
+ const where = opts.where?.trim() ? ` WHERE ${opts.where}` : "";
243
+ const order = opts.orderBy ? ` ORDER BY ${quote$1(opts.orderBy.column)} ${opts.orderBy.direction === "desc" ? "DESC" : "ASC"}` : "";
244
+ const { meta, data } = await fetchRows(client, `SELECT * FROM ${qualified$1(ref)}${where}${order} LIMIT {limit:UInt32} OFFSET {offset:UInt64}`, {
245
+ limit: opts.limit,
246
+ offset: opts.offset
247
+ });
248
+ const counted = await objects(client, `SELECT count() AS n FROM ${qualified$1(ref)}${where}`);
249
+ return {
250
+ result: {
251
+ columns: meta,
252
+ rows: data,
253
+ rowCount: data.length,
254
+ elapsedMs: Math.round(performance.now() - started),
255
+ truncated: false
256
+ },
257
+ total: Number(counted[0]?.n ?? 0)
258
+ };
259
+ },
260
+ async updateRow(conn, ref, key, changes) {
261
+ const cols = Object.keys(changes);
262
+ if (cols.length === 0) return;
263
+ const client = getClient(conn);
264
+ const types = await columnTypes(client, ref);
265
+ const params = new Params();
266
+ const set = cols.map((c) => {
267
+ const v = changes[c];
268
+ return `${quote$1(c)} = ${v === null ? "NULL" : params.bind(v, types.get(c) ?? "String")}`;
269
+ }).join(", ");
270
+ const where = condition(key, types, params);
271
+ await client.command({
272
+ query: `ALTER TABLE ${qualified$1(ref)} UPDATE ${set} WHERE ${where}`,
273
+ query_params: params.values,
274
+ clickhouse_settings: MUTATION_SETTINGS
275
+ });
276
+ },
277
+ async insertRow(conn, ref, values) {
278
+ await getClient(conn).insert({
279
+ table: qualified$1(ref),
280
+ values: [values],
281
+ format: "JSONEachRow"
282
+ });
283
+ },
284
+ async deleteRows(conn, ref, keys) {
285
+ if (keys.length === 0) return;
286
+ const client = getClient(conn);
287
+ const types = await columnTypes(client, ref);
288
+ const params = new Params();
289
+ const where = keys.map((k) => `(${condition(k, types, params)})`).join(" OR ");
290
+ await client.command({
291
+ query: `ALTER TABLE ${qualified$1(ref)} DELETE WHERE ${where}`,
292
+ query_params: params.values,
293
+ clickhouse_settings: MUTATION_SETTINGS
294
+ });
295
+ },
296
+ async dispose(id) {
297
+ const cached = clients.get(id);
298
+ clients.delete(id);
299
+ await closeAll(cached);
300
+ }
301
+ };
302
+ // src/server/drivers/mysql.ts
303
+ var pools = /* @__PURE__ */ new Map();
304
+ function getPool(conn) {
305
+ const key = fingerprint([
306
+ conn.host,
307
+ conn.port,
308
+ conn.user,
309
+ conn.password,
310
+ conn.database,
311
+ conn.secure
312
+ ]);
313
+ const cached = pools.get(conn.id);
314
+ if (cached?.key === key) return cached.pool;
315
+ cached?.pool.end().catch(() => {});
316
+ const pool = mysql.createPool({
317
+ host: conn.host,
318
+ port: conn.port,
319
+ user: conn.user,
320
+ password: conn.password,
321
+ database: conn.database || void 0,
322
+ ssl: conn.secure ? {} : void 0,
323
+ connectionLimit: 4,
324
+ connectTimeout: 8e3,
325
+ dateStrings: true,
326
+ supportBigNumbers: true,
327
+ bigNumberStrings: true
328
+ });
329
+ pools.set(conn.id, {
330
+ key,
331
+ pool
332
+ });
333
+ return pool;
334
+ }
335
+ var NO_MATCH = "No row matched the key. The row may have changed or been deleted.";
336
+ var quote = (name) => `\`${name.replaceAll("`", "``")}\``;
337
+ var qualified = (ref) => `${quote(ref.database)}.${quote(ref.table)}`;
338
+ var typeNames = new Map(Object.entries(mysqlCore.Types).map(([name, id]) => [id, name]));
339
+ function fieldType(field) {
340
+ const id = field.columnType;
341
+ return id !== void 0 && typeNames.get(id) || "UNKNOWN";
342
+ }
343
+ function whereKey(key) {
344
+ const params = [];
345
+ return {
346
+ sql: Object.entries(key).map(([col, value]) => {
347
+ if (value === null) return `${quote(col)} IS NULL`;
348
+ params.push(value);
349
+ return `${quote(col)} = ?`;
350
+ }).join(" AND "),
351
+ params
352
+ };
353
+ }
354
+ async function inTransaction$1(pool, fn) {
355
+ const c = await pool.getConnection();
356
+ try {
357
+ await c.beginTransaction();
358
+ const out = await fn(c);
359
+ await c.commit();
360
+ return out;
361
+ } catch (error) {
362
+ await c.rollback().catch(() => {});
363
+ throw error;
364
+ } finally {
365
+ c.release();
366
+ }
367
+ }
368
+ async function run(pool, sql, params, limit, session) {
369
+ const started = performance.now();
370
+ const conn = await pool.getConnection();
371
+ let overflowed = false;
372
+ try {
373
+ if (session?.use) await conn.query("USE ??", [session.use]);
374
+ if (session?.readOnly) {
375
+ await conn.query("SET SESSION TRANSACTION READ ONLY");
376
+ await conn.query("START TRANSACTION READ ONLY");
377
+ }
378
+ return {
379
+ ...await new Promise((resolve, reject) => {
380
+ const rows = [];
381
+ let fields;
382
+ let affectedRows;
383
+ const finish = (truncated) => resolve({
384
+ columns: fields?.map((f) => ({
385
+ name: f.name,
386
+ type: fieldType(f)
387
+ })) ?? [],
388
+ rows,
389
+ rowCount: rows.length,
390
+ truncated,
391
+ ...affectedRows === void 0 ? {} : { affectedRows }
392
+ });
393
+ const query = conn.connection.query({
394
+ sql,
395
+ values: params,
396
+ rowsAsArray: true
397
+ });
398
+ query.on("fields", (f) => {
399
+ fields = f;
400
+ });
401
+ query.on("result", (row) => {
402
+ if (!Array.isArray(row)) {
403
+ affectedRows = row.affectedRows;
404
+ return;
405
+ }
406
+ if (rows.length >= limit) {
407
+ overflowed = true;
408
+ conn.destroy();
409
+ finish(true);
410
+ return;
411
+ }
412
+ rows.push(row.map(normalizeCell));
413
+ });
414
+ query.on("error", reject);
415
+ query.on("end", () => finish(false));
416
+ }),
417
+ elapsedMs: Math.round(performance.now() - started)
418
+ };
419
+ } finally {
420
+ if (!overflowed) {
421
+ if (session?.discard) conn.destroy();
422
+ else conn.release();
423
+ }
424
+ }
425
+ }
426
+ async function rowsOf(pool, sql, params) {
427
+ const [rows] = await pool.query({
428
+ sql,
429
+ values: params
430
+ });
431
+ return rows;
432
+ }
433
+ // src/server/drivers/index.ts
434
+ var drivers = {
435
+ mysql: {
436
+ async test(conn) {
437
+ const rows = await rowsOf(getPool(conn), "SELECT VERSION() AS v", []);
438
+ return { version: String(rows[0]?.v) };
439
+ },
440
+ async listDatabases(conn) {
441
+ return (await rowsOf(getPool(conn), "SELECT schema_name AS name FROM information_schema.schemata ORDER BY schema_name", [])).map((r) => String(r.name));
442
+ },
443
+ async listTables(conn, database) {
444
+ return (await rowsOf(getPool(conn), "SELECT table_name AS name, table_type AS type FROM information_schema.tables WHERE table_schema = ? ORDER BY table_name", [database])).map((r) => ({
445
+ name: String(r.name),
446
+ isView: r.type === "VIEW"
447
+ }));
448
+ },
449
+ async describeTable(conn, ref) {
450
+ const pool = getPool(conn);
451
+ const columns = (await rowsOf(pool, `SELECT column_name AS name, column_type AS type, is_nullable AS nullable,
452
+ column_key AS col_key, column_default AS dflt
453
+ FROM information_schema.columns
454
+ WHERE table_schema = ? AND table_name = ?
455
+ ORDER BY ordinal_position`, [ref.database, ref.table])).map((r) => ({
456
+ name: String(r.name),
457
+ type: String(r.type),
458
+ nullable: r.nullable === "YES",
459
+ isPrimaryKey: r.col_key === "PRI",
460
+ default: r.dflt === null || r.dflt === void 0 ? null : String(r.dflt)
461
+ }));
462
+ const tables = await rowsOf(pool, "SELECT table_type AS type FROM information_schema.tables WHERE table_schema = ? AND table_name = ?", [ref.database, ref.table]);
463
+ const keyColumns = columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
464
+ return {
465
+ columns,
466
+ keyColumns,
467
+ editBlockedReason: tables[0]?.type === "VIEW" ? "Views are read-only." : keyColumns.length === 0 ? "This table has no primary key, so rows cannot be addressed safely. Editing is disabled." : null
468
+ };
469
+ },
470
+ async sqlSchema(conn) {
471
+ const rows = await rowsOf(getPool(conn), `SELECT table_schema AS db, table_name AS tbl, column_name AS col, column_type AS type
472
+ FROM information_schema.columns
473
+ WHERE table_schema NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
474
+ OR table_schema = ?
475
+ ORDER BY table_schema = ? DESC, table_schema, table_name, ordinal_position
476
+ LIMIT ${SQL_SCHEMA_ROW_CAP}`, [conn.database ?? "", conn.database ?? ""]);
477
+ return {
478
+ defaultDatabase: conn.database || null,
479
+ schema: groupColumns(rows)
480
+ };
481
+ },
482
+ query(conn, sql, { database, limit, readOnly }) {
483
+ const use = database ?? (conn.database || void 0);
484
+ return run(getPool(conn), sql, void 0, limit, {
485
+ use,
486
+ discard: true,
487
+ readOnly
488
+ });
489
+ },
490
+ async selectRows(conn, ref, opts) {
491
+ const pool = getPool(conn);
492
+ const where = opts.where?.trim() ? ` WHERE ${opts.where}` : "";
493
+ const order = opts.orderBy ? ` ORDER BY ${quote(opts.orderBy.column)} ${opts.orderBy.direction === "desc" ? "DESC" : "ASC"}` : "";
494
+ const result = await run(pool, `SELECT * FROM ${qualified(ref)}${where}${order} LIMIT ? OFFSET ?`, [opts.limit, opts.offset], opts.limit);
495
+ const counted = await rowsOf(pool, `SELECT COUNT(*) AS n FROM ${qualified(ref)}${where}`, []);
496
+ return {
497
+ result,
498
+ total: Number(counted[0]?.n ?? 0)
499
+ };
500
+ },
501
+ async updateRow(conn, ref, key, changes) {
502
+ const cols = Object.keys(changes);
503
+ if (cols.length === 0) return;
504
+ const w = whereKey(key);
505
+ const sql = `UPDATE ${qualified(ref)} SET ${cols.map((c) => `${quote(c)} = ?`).join(", ")} WHERE ${w.sql} LIMIT 1`;
506
+ const [header] = await getPool(conn).query(sql, [...cols.map((c) => changes[c]), ...w.params]);
507
+ if (header.affectedRows === 0) throw new Error(NO_MATCH);
508
+ },
509
+ async insertRow(conn, ref, values) {
510
+ const cols = Object.keys(values);
511
+ const sql = cols.length === 0 ? `INSERT INTO ${qualified(ref)} () VALUES ()` : `INSERT INTO ${qualified(ref)} (${cols.map(quote).join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`;
512
+ await getPool(conn).query(sql, cols.map((c) => values[c]));
513
+ },
514
+ async deleteRows(conn, ref, keys) {
515
+ await inTransaction$1(getPool(conn), async (c) => {
516
+ for (const key of keys) {
517
+ const w = whereKey(key);
518
+ const [header] = await c.query(`DELETE FROM ${qualified(ref)} WHERE ${w.sql} LIMIT 1`, w.params);
519
+ if (header.affectedRows === 0) throw new Error(NO_MATCH);
520
+ }
521
+ });
522
+ },
523
+ async dispose(id) {
524
+ const cached = pools.get(id);
525
+ pools.delete(id);
526
+ await cached?.pool.end().catch(() => {});
527
+ }
528
+ },
529
+ clickhouse: clickhouseDriver
530
+ };
531
+ // src/server/store.ts
532
+ var db;
533
+ function open() {
534
+ if (db) return db;
535
+ const file = resolve(process.env.DB_PLAYER_DATA ?? "data/db-player.sqlite");
536
+ const dir = dirname(file);
537
+ mkdirSync(dir, {
538
+ recursive: true,
539
+ mode: 448
540
+ });
541
+ chmodSync(dir, 448);
542
+ closeSync(openSync(file, "a", 384));
543
+ chmodSync(file, 384);
544
+ db = new DatabaseSync(file);
545
+ db.exec(`
546
+ PRAGMA foreign_keys = ON;
547
+ CREATE TABLE IF NOT EXISTS folders (
548
+ id TEXT PRIMARY KEY,
549
+ name TEXT NOT NULL,
550
+ color TEXT NOT NULL,
551
+ position INTEGER NOT NULL
552
+ );
553
+ CREATE TABLE IF NOT EXISTS connections (
554
+ id TEXT PRIMARY KEY,
555
+ folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
556
+ name TEXT NOT NULL,
557
+ kind TEXT NOT NULL,
558
+ host TEXT NOT NULL,
559
+ port INTEGER NOT NULL,
560
+ user TEXT NOT NULL,
561
+ password TEXT NOT NULL,
562
+ database TEXT,
563
+ secure INTEGER NOT NULL DEFAULT 0
564
+ );
565
+ CREATE TABLE IF NOT EXISTS tabs (
566
+ id TEXT PRIMARY KEY,
567
+ kind TEXT NOT NULL,
568
+ connection_id TEXT NOT NULL,
569
+ database TEXT,
570
+ table_name TEXT,
571
+ serial INTEGER,
572
+ sql TEXT NOT NULL DEFAULT '',
573
+ position INTEGER NOT NULL
574
+ );
575
+ CREATE TABLE IF NOT EXISTS workspace (
576
+ id INTEGER PRIMARY KEY CHECK (id = 1),
577
+ active_tab_id TEXT
578
+ );
579
+ INSERT OR IGNORE INTO workspace (id, active_tab_id) VALUES (1, NULL);
580
+ CREATE TABLE IF NOT EXISTS settings (
581
+ key TEXT PRIMARY KEY,
582
+ value TEXT NOT NULL
583
+ );
584
+ `);
585
+ migrateWorkspaceRevision(db);
586
+ return db;
587
+ }
588
+ /**
589
+ * `workspace.revision` counts every change to the tabs or the active tab, so a client can tell which
590
+ * of two snapshots is newer. Triggers bump it, so no writer can forget and a new one is covered.
591
+ */
592
+ function migrateWorkspaceRevision(d) {
593
+ if (!d.prepare("PRAGMA table_info(workspace)").all().some((c) => c.name === "revision")) d.exec("ALTER TABLE workspace ADD COLUMN revision INTEGER NOT NULL DEFAULT 0");
594
+ const bump = "UPDATE workspace SET revision = revision + 1 WHERE id = 1;";
595
+ d.exec(`
596
+ CREATE TRIGGER IF NOT EXISTS tabs_revision_insert AFTER INSERT ON tabs BEGIN ${bump} END;
597
+ CREATE TRIGGER IF NOT EXISTS tabs_revision_update AFTER UPDATE ON tabs BEGIN ${bump} END;
598
+ CREATE TRIGGER IF NOT EXISTS tabs_revision_delete AFTER DELETE ON tabs BEGIN ${bump} END;
599
+ CREATE TRIGGER IF NOT EXISTS workspace_revision_active
600
+ AFTER UPDATE OF active_tab_id ON workspace
601
+ BEGIN ${bump} END;
602
+ `);
603
+ }
604
+ var toFolder = (r) => ({
605
+ id: r.id,
606
+ name: r.name,
607
+ color: r.color,
608
+ position: r.position
609
+ });
610
+ var toConnection = (r) => ({
611
+ id: r.id,
612
+ folderId: r.folder_id,
613
+ name: r.name,
614
+ kind: r.kind,
615
+ host: r.host,
616
+ port: r.port,
617
+ user: r.user,
618
+ password: r.password,
619
+ database: r.database,
620
+ secure: r.secure === 1
621
+ });
622
+ function toSummary({ password, ...rest }) {
623
+ return {
624
+ ...rest,
625
+ hasPassword: password.length > 0
626
+ };
627
+ }
628
+ function listFolders() {
629
+ return open().prepare("SELECT * FROM folders ORDER BY position, name").all().map(toFolder);
630
+ }
631
+ function createFolder(input) {
632
+ const d = open();
633
+ const next = d.prepare("SELECT COALESCE(MAX(position), -1) + 1 AS p FROM folders").get();
634
+ const id = crypto.randomUUID();
635
+ d.prepare("INSERT INTO folders (id, name, color, position) VALUES (?, ?, ?, ?)").run(id, input.name, input.color, next.p);
636
+ return {
637
+ id,
638
+ name: input.name,
639
+ color: input.color,
640
+ position: next.p
641
+ };
642
+ }
643
+ function updateFolder(id, input) {
644
+ open().prepare("UPDATE folders SET name = ?, color = ? WHERE id = ?").run(input.name, input.color, id);
645
+ }
646
+ function deleteFolder(id) {
647
+ open().prepare("DELETE FROM folders WHERE id = ?").run(id);
648
+ }
649
+ function listConnections() {
650
+ return open().prepare("SELECT * FROM connections ORDER BY name").all().map(toConnection);
651
+ }
652
+ function getConnection(id) {
653
+ const row = open().prepare("SELECT * FROM connections WHERE id = ?").get(id);
654
+ if (!row) throw new Error("Connection not found");
655
+ return toConnection(row);
656
+ }
657
+ function createConnection(input) {
658
+ const id = crypto.randomUUID();
659
+ open().prepare(`INSERT INTO connections (id, folder_id, name, kind, host, port, user, password, database, secure)
660
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.folderId, input.name, input.kind, input.host, input.port, input.user, input.password, input.database || null, input.secure ? 1 : 0);
661
+ return getConnection(id);
662
+ }
663
+ function updateConnection(id, input) {
664
+ const current = getConnection(id);
665
+ const password = input.password === "" ? current.password : input.password;
666
+ open().prepare(`UPDATE connections SET folder_id = ?, name = ?, kind = ?, host = ?, port = ?, user = ?,
667
+ password = ?, database = ?, secure = ? WHERE id = ?`).run(input.folderId, input.name, input.kind, input.host, input.port, input.user, password, input.database || null, input.secure ? 1 : 0, id);
668
+ return getConnection(id);
669
+ }
670
+ function moveConnection(id, folderId) {
671
+ open().prepare("UPDATE connections SET folder_id = ? WHERE id = ?").run(folderId, id);
672
+ }
673
+ function deleteConnection(id) {
674
+ return inTransaction(() => {
675
+ const doomed = listTabs().filter((t) => t.target.connectionId === id).map((t) => t.id);
676
+ if (doomed.length > 0) removeTabs(doomed);
677
+ open().prepare("DELETE FROM connections WHERE id = ?").run(id);
678
+ return getWorkspace();
679
+ });
680
+ }
681
+ function inTransaction(fn) {
682
+ const d = open();
683
+ d.exec("BEGIN IMMEDIATE");
684
+ try {
685
+ const out = fn();
686
+ d.exec("COMMIT");
687
+ return out;
688
+ } catch (error) {
689
+ d.exec("ROLLBACK");
690
+ throw error;
691
+ }
692
+ }
693
+ function toTab(r) {
694
+ const target = r.kind === "table" ? {
695
+ kind: "table",
696
+ connectionId: r.connection_id,
697
+ database: r.database ?? "",
698
+ table: r.table_name ?? ""
699
+ } : {
700
+ kind: "sql",
701
+ connectionId: r.connection_id,
702
+ database: r.database,
703
+ serial: r.serial ?? 1
704
+ };
705
+ return {
706
+ id: r.id,
707
+ target,
708
+ sql: r.sql,
709
+ position: r.position
710
+ };
711
+ }
712
+ function listTabs() {
713
+ return open().prepare("SELECT * FROM tabs ORDER BY position").all().map(toTab);
714
+ }
715
+ function readActive() {
716
+ return open().prepare("SELECT active_tab_id AS id FROM workspace WHERE id = 1").get().id;
717
+ }
718
+ function readRevision() {
719
+ return open().prepare("SELECT revision FROM workspace WHERE id = 1").get().revision;
720
+ }
721
+ function writeActive(id) {
722
+ open().prepare("UPDATE workspace SET active_tab_id = ? WHERE id = 1").run(id);
723
+ }
724
+ function writeOrder(ids) {
725
+ const stmt = open().prepare("UPDATE tabs SET position = ? WHERE id = ?");
726
+ ids.forEach((id, i) => stmt.run(i, id));
727
+ }
728
+ function getWorkspace() {
729
+ const tabs = listTabs();
730
+ const active = readActive();
731
+ return {
732
+ tabs,
733
+ activeTabId: tabs.some((t) => t.id === active) ? active : tabs[0]?.id ?? null,
734
+ revision: readRevision()
735
+ };
736
+ }
737
+ var sameTable = (a, b) => a.kind === "table" && b.kind === "table" && a.connectionId === b.connectionId && a.database === b.database && a.table === b.table;
738
+ /** Table tabs are identified by their table, so opening one twice focuses it. SQL tabs are always new. */
739
+ function openTab(target, opts) {
740
+ return inTransaction(() => {
741
+ const tabs = listTabs();
742
+ const existing = tabs.find((t) => sameTable(t.target, target));
743
+ if (existing) {
744
+ writeActive(existing.id);
745
+ return {
746
+ workspace: getWorkspace(),
747
+ tabId: existing.id
748
+ };
749
+ }
750
+ const id = crypto.randomUUID();
751
+ let serial = null;
752
+ if (target.kind === "sql") {
753
+ const taken = new Set(tabs.flatMap((t) => t.target.kind === "sql" && t.target.connectionId === target.connectionId ? [t.target.serial] : []));
754
+ serial = 1;
755
+ while (taken.has(serial)) serial++;
756
+ }
757
+ const ids = tabs.map((t) => t.id);
758
+ const anchor = opts.after ?? readActive();
759
+ const anchorAt = anchor ? ids.indexOf(anchor) : -1;
760
+ ids.splice(anchorAt === -1 ? ids.length : anchorAt + 1, 0, id);
761
+ open().prepare(`INSERT INTO tabs (id, kind, connection_id, database, table_name, serial, sql, position)
762
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, target.kind, target.connectionId, target.database, target.kind === "table" ? target.table : null, serial, target.kind === "sql" ? opts.sql : "", ids.indexOf(id));
763
+ writeOrder(ids);
764
+ writeActive(id);
765
+ return {
766
+ workspace: getWorkspace(),
767
+ tabId: id
768
+ };
769
+ });
770
+ }
771
+ /**
772
+ * Removes the tabs. When the active one goes, `prefer` takes over if it survives, else the right
773
+ * neighbour of the active tab, else the left.
774
+ */
775
+ function removeTabs(doomed, prefer) {
776
+ const ordered = listTabs().map((t) => t.id);
777
+ const gone = new Set(doomed);
778
+ const active = getWorkspace().activeTabId;
779
+ const stmt = open().prepare("DELETE FROM tabs WHERE id = ?");
780
+ for (const id of gone) stmt.run(id);
781
+ const survivors = ordered.filter((id) => !gone.has(id));
782
+ writeOrder(survivors);
783
+ if (active && !gone.has(active)) return;
784
+ if (prefer && survivors.includes(prefer)) return writeActive(prefer);
785
+ const at = active ? ordered.indexOf(active) : -1;
786
+ const right = ordered.slice(at + 1).find((id) => !gone.has(id));
787
+ const left = ordered.slice(0, Math.max(at, 0)).reverse().find((id) => !gone.has(id));
788
+ writeActive(right ?? left ?? null);
789
+ }
790
+ function closeTabs(ids, prefer) {
791
+ return inTransaction(() => {
792
+ removeTabs(ids, prefer);
793
+ return getWorkspace();
794
+ });
795
+ }
796
+ function reorderTabs(ids) {
797
+ return inTransaction(() => {
798
+ const current = listTabs().map((t) => t.id);
799
+ if (ids.length !== current.length || !current.every((id) => ids.includes(id))) throw new Error("Tab order does not match the open tabs.");
800
+ writeOrder(ids);
801
+ return getWorkspace();
802
+ });
803
+ }
804
+ function setActiveTab(id) {
805
+ if (!listTabs().some((t) => t.id === id)) return;
806
+ writeActive(id);
807
+ }
808
+ function updateTabDraft(id, sql) {
809
+ open().prepare("UPDATE tabs SET sql = ? WHERE id = ? AND kind = 'sql'").run(sql, id);
810
+ }
811
+ function setTabDatabase(id, database) {
812
+ open().prepare("UPDATE tabs SET database = ? WHERE id = ? AND kind = 'sql'").run(database, id);
813
+ }
814
+ var MCP_TOKEN_KEY = "mcp_token";
815
+ var newToken = () => Buffer.from(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32))).toString("base64url");
816
+ /** Generated on first use. `INSERT OR IGNORE` keeps concurrent first callers on one token. */
817
+ function getMcpToken() {
818
+ const d = open();
819
+ d.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)").run(MCP_TOKEN_KEY, newToken());
820
+ return d.prepare("SELECT value FROM settings WHERE key = ?").get(MCP_TOKEN_KEY).value;
821
+ }
822
+ function regenerateMcpToken() {
823
+ const token = newToken();
824
+ open().prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(MCP_TOKEN_KEY, token);
825
+ return token;
826
+ }
827
+ export { DB_KIND_INFO as C, folderInputSchema as D, connectionInputSchema as E, newTabTargetSchema as O, DB_KINDS as S, FOLDER_COLOR_NAMES as T, toSummary as _, deleteFolder as a, updateTabDraft as b, getWorkspace as c, moveConnection as d, openTab as f, setTabDatabase as g, setActiveTab as h, deleteConnection as i, tabIdSchema as k, listConnections as l, reorderTabs as m, createConnection as n, getConnection as o, regenerateMcpToken as p, createFolder as r, getMcpToken as s, closeTabs as t, listFolders as u, updateConnection as v, FOLDER_COLORS as w, drivers as x, updateFolder as y };