planche 0.2.0 → 0.4.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.
- package/README.md +10 -2
- package/dist/index.js +491 -21
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# planche
|
|
2
2
|
|
|
3
3
|
Local agent for [Planche](https://planche.dev), a browser-based SQL client for
|
|
4
|
-
MySQL, MariaDB, PostgreSQL and
|
|
4
|
+
MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, Neon, Supabase and
|
|
5
|
+
Cloudflare D1.
|
|
5
6
|
|
|
6
7
|
Browsers cannot open TCP connections to a database, so Planche runs this small
|
|
7
8
|
agent on your machine. The app in your browser talks to the agent over
|
|
@@ -90,7 +91,14 @@ database connections open and forwards queries with the matching driver
|
|
|
90
91
|
- **SQLite** — opens a file on your disk by path. Set `PLANCHE_SQLITE_DIR` to
|
|
91
92
|
limit which files can be opened. (Small SQLite files can also be opened
|
|
92
93
|
directly in the browser without the agent.)
|
|
93
|
-
- **
|
|
94
|
+
- **SQL Server** — same model as PostgreSQL: the connection is bound to one
|
|
95
|
+
database and the app's "database" is a schema (`dbo`, ...). `USE` works in
|
|
96
|
+
the editor because every statement runs on the same session; scripts may
|
|
97
|
+
contain `GO` lines.
|
|
98
|
+
- **Oracle** — thin-mode driver, no Instant Client needed. Schemas (users) are
|
|
99
|
+
the app's "databases", switched with `ALTER SESSION SET CURRENT_SCHEMA`.
|
|
100
|
+
PL/SQL blocks end with a `/` line as in SQL*Plus.
|
|
101
|
+
- **SSH tunnel** — MySQL, PostgreSQL, SQL Server and Oracle profiles can go through an SSH jump
|
|
94
102
|
host (password, private key, or the local `ssh-agent`). The agent opens the
|
|
95
103
|
tunnel with `ssh2` on a loopback port and points the driver at it. The host
|
|
96
104
|
may be a `Host` alias from `~/.ssh/config`: `HostName`, `Port`, `User` and
|
package/dist/index.js
CHANGED
|
@@ -80,13 +80,13 @@ var MySqlDriver = class {
|
|
|
80
80
|
* dropping temporary tables and user variables.
|
|
81
81
|
*/
|
|
82
82
|
currentDatabase;
|
|
83
|
-
async query(
|
|
83
|
+
async query(sql2, database) {
|
|
84
84
|
if (database && database !== this.currentDatabase) {
|
|
85
85
|
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
86
86
|
this.currentDatabase = database;
|
|
87
87
|
}
|
|
88
|
-
const [results, fields] = await this.connection.query(
|
|
89
|
-
if (USE_STATEMENT.test(
|
|
88
|
+
const [results, fields] = await this.connection.query(sql2);
|
|
89
|
+
if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
|
|
90
90
|
if (Array.isArray(results) && fields) {
|
|
91
91
|
const fieldPackets = fields;
|
|
92
92
|
const mappedFields = fieldPackets.map((field) => ({
|
|
@@ -209,7 +209,7 @@ function toClientConfig(config) {
|
|
|
209
209
|
user: config.user,
|
|
210
210
|
password: config.password,
|
|
211
211
|
database: config.database,
|
|
212
|
-
ssl: config.ssl ? { rejectUnauthorized:
|
|
212
|
+
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
|
|
213
213
|
types
|
|
214
214
|
};
|
|
215
215
|
}
|
|
@@ -269,13 +269,13 @@ var PostgresDriver = class {
|
|
|
269
269
|
} catch {
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
-
async query(
|
|
272
|
+
async query(sql2, database) {
|
|
273
273
|
if (database && database !== this.currentSchema) {
|
|
274
274
|
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
275
275
|
this.currentSchema = database;
|
|
276
276
|
}
|
|
277
|
-
const result = await this.client.query({ text:
|
|
278
|
-
if (SEARCH_PATH_STATEMENT.test(
|
|
277
|
+
const result = await this.client.query({ text: sql2, rowMode: "array" });
|
|
278
|
+
if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
|
|
279
279
|
const results = Array.isArray(result) ? result : [result];
|
|
280
280
|
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
281
281
|
if (last.fields.length > 0) {
|
|
@@ -332,7 +332,8 @@ var postgresDriverFactory = {
|
|
|
332
332
|
user,
|
|
333
333
|
password: typeof body.password === "string" ? body.password : "",
|
|
334
334
|
database: typeof body.database === "string" && body.database ? body.database : "postgres",
|
|
335
|
-
ssl: body.ssl === true
|
|
335
|
+
ssl: body.ssl === true,
|
|
336
|
+
sslVerify: body.sslVerify === true
|
|
336
337
|
};
|
|
337
338
|
}
|
|
338
339
|
};
|
|
@@ -385,13 +386,13 @@ var SqliteDriver = class {
|
|
|
385
386
|
db;
|
|
386
387
|
type = "sqlite";
|
|
387
388
|
label;
|
|
388
|
-
async query(
|
|
389
|
+
async query(sql2) {
|
|
389
390
|
let statement;
|
|
390
391
|
try {
|
|
391
|
-
statement = this.db.prepare(
|
|
392
|
+
statement = this.db.prepare(sql2);
|
|
392
393
|
} catch (err) {
|
|
393
394
|
if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
|
|
394
|
-
this.db.exec(
|
|
395
|
+
this.db.exec(sql2);
|
|
395
396
|
return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
|
|
396
397
|
}
|
|
397
398
|
throw err;
|
|
@@ -466,6 +467,460 @@ var sqliteDriverFactory = {
|
|
|
466
467
|
}
|
|
467
468
|
};
|
|
468
469
|
|
|
470
|
+
// src/drivers/mssql.ts
|
|
471
|
+
import sql from "mssql";
|
|
472
|
+
function toPoolConfig(config) {
|
|
473
|
+
return {
|
|
474
|
+
server: config.host,
|
|
475
|
+
port: config.port,
|
|
476
|
+
user: config.user,
|
|
477
|
+
password: config.password,
|
|
478
|
+
database: config.database,
|
|
479
|
+
// One physical connection: manual transactions and USE need session affinity.
|
|
480
|
+
pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
|
|
481
|
+
options: {
|
|
482
|
+
encrypt: config.ssl === true,
|
|
483
|
+
trustServerCertificate: true,
|
|
484
|
+
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
485
|
+
useUTC: false,
|
|
486
|
+
enableArithAbort: true
|
|
487
|
+
},
|
|
488
|
+
requestTimeout: 0
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
var pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
492
|
+
function formatDate(d) {
|
|
493
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
494
|
+
const ms = d.getMilliseconds();
|
|
495
|
+
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
|
|
496
|
+
}
|
|
497
|
+
function normalizeValue(value) {
|
|
498
|
+
if (value instanceof Date) return formatDate(value);
|
|
499
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
|
|
503
|
+
var MssqlDriver = class {
|
|
504
|
+
constructor(pool, config) {
|
|
505
|
+
this.pool = pool;
|
|
506
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
507
|
+
}
|
|
508
|
+
pool;
|
|
509
|
+
type = "mssql";
|
|
510
|
+
label;
|
|
511
|
+
/** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
|
|
512
|
+
sources = /* @__PURE__ */ new Map();
|
|
513
|
+
/**
|
|
514
|
+
* The `mssql` driver does not report which table a result column comes
|
|
515
|
+
* from; ask the server to describe the statement instead so the grid can
|
|
516
|
+
* decide whether it is editable and address rows by primary key.
|
|
517
|
+
*/
|
|
518
|
+
async describe(text) {
|
|
519
|
+
const cached = this.sources.get(text);
|
|
520
|
+
if (cached !== void 0) return cached;
|
|
521
|
+
let result = null;
|
|
522
|
+
try {
|
|
523
|
+
const req = this.pool.request();
|
|
524
|
+
req.input("sql", sql.NVarChar(sql.MAX), text);
|
|
525
|
+
const res = await req.query(
|
|
526
|
+
"SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
|
|
527
|
+
);
|
|
528
|
+
result = res.recordset.map((r) => ({
|
|
529
|
+
schema: r.source_schema ?? "",
|
|
530
|
+
table: r.source_table ?? "",
|
|
531
|
+
column: r.source_column ?? "",
|
|
532
|
+
type: (r.system_type_name ?? "").toLowerCase()
|
|
533
|
+
}));
|
|
534
|
+
} catch {
|
|
535
|
+
result = null;
|
|
536
|
+
}
|
|
537
|
+
if (this.sources.size > 200) this.sources.clear();
|
|
538
|
+
this.sources.set(text, result);
|
|
539
|
+
return result;
|
|
540
|
+
}
|
|
541
|
+
async query(text) {
|
|
542
|
+
const req = this.pool.request();
|
|
543
|
+
req.arrayRowMode = true;
|
|
544
|
+
const res = await req.query(text);
|
|
545
|
+
const sets = res.recordsets;
|
|
546
|
+
const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
|
|
547
|
+
const columns = last?.columns;
|
|
548
|
+
if (last && columns) {
|
|
549
|
+
const meta = Object.values(columns).sort((a, b) => a.index - b.index);
|
|
550
|
+
const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
|
|
551
|
+
const fields = meta.map((col, i) => {
|
|
552
|
+
const src = sources && sources.length === meta.length ? sources[i] : void 0;
|
|
553
|
+
const typeName = col.type?.declaration ?? "";
|
|
554
|
+
return {
|
|
555
|
+
name: col.name,
|
|
556
|
+
orgName: src?.column || col.name,
|
|
557
|
+
type: src?.type || typeName.toLowerCase(),
|
|
558
|
+
table: src?.table ?? "",
|
|
559
|
+
orgTable: src?.table ?? "",
|
|
560
|
+
db: src?.schema ?? "",
|
|
561
|
+
maxLength: typeof col.length === "number" ? col.length : 0
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
const records = last.map((row) => row.map(normalizeValue));
|
|
565
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
fields: [],
|
|
569
|
+
records: [],
|
|
570
|
+
affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
|
|
571
|
+
insertId: 0,
|
|
572
|
+
isResultQuery: false
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
async close() {
|
|
576
|
+
await this.pool.close();
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
var mssqlDriverFactory = {
|
|
580
|
+
async open(config) {
|
|
581
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
582
|
+
await pool.connect();
|
|
583
|
+
return new MssqlDriver(pool, config);
|
|
584
|
+
},
|
|
585
|
+
async test(config) {
|
|
586
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
587
|
+
await pool.connect();
|
|
588
|
+
await pool.close();
|
|
589
|
+
},
|
|
590
|
+
parseConfig(body) {
|
|
591
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
592
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
593
|
+
if (!host || !user) {
|
|
594
|
+
throw new Error("host and user are required");
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
driver: "mssql",
|
|
598
|
+
host,
|
|
599
|
+
port: Number(body.port) || 1433,
|
|
600
|
+
user,
|
|
601
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
602
|
+
database: typeof body.database === "string" && body.database ? body.database : "master",
|
|
603
|
+
ssl: body.ssl === true
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// src/drivers/oracle.ts
|
|
609
|
+
import oracledb from "oracledb";
|
|
610
|
+
oracledb.autoCommit = true;
|
|
611
|
+
oracledb.fetchAsString = [oracledb.CLOB];
|
|
612
|
+
var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
|
|
613
|
+
var pad2 = (n, width = 2) => String(n).padStart(width, "0");
|
|
614
|
+
function formatDate2(d) {
|
|
615
|
+
const ms = d.getMilliseconds();
|
|
616
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
|
|
617
|
+
}
|
|
618
|
+
function normalizeValue2(value) {
|
|
619
|
+
if (value instanceof Date) return formatDate2(value);
|
|
620
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
621
|
+
return value;
|
|
622
|
+
}
|
|
623
|
+
var SESSION_FORMATS = [
|
|
624
|
+
"ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
|
|
625
|
+
"ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
|
|
626
|
+
"ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
|
|
627
|
+
];
|
|
628
|
+
var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
629
|
+
var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
|
|
630
|
+
function normalizeStatement(sql2) {
|
|
631
|
+
const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
|
|
632
|
+
if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
|
633
|
+
return trimmed.replace(/;\s*$/, "");
|
|
634
|
+
}
|
|
635
|
+
var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
|
|
636
|
+
var START_TX = /^\s*SET\s+TRANSACTION\b/i;
|
|
637
|
+
var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
|
|
638
|
+
function singleSourceTable(sql2) {
|
|
639
|
+
const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
|
|
640
|
+
if (!m) return null;
|
|
641
|
+
const rest = m[3].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|FETCH|OFFSET|UNION|MINUS|INTERSECT|FOR\s+UPDATE)\b[\s\S]*$/i, "");
|
|
642
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
643
|
+
const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
|
|
644
|
+
return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
|
|
645
|
+
}
|
|
646
|
+
var OracleDriver = class {
|
|
647
|
+
constructor(conn, config) {
|
|
648
|
+
this.conn = conn;
|
|
649
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
|
|
650
|
+
this.currentSchema = config.user.toUpperCase();
|
|
651
|
+
}
|
|
652
|
+
conn;
|
|
653
|
+
type = "oracle";
|
|
654
|
+
label;
|
|
655
|
+
currentSchema;
|
|
656
|
+
manualTx = false;
|
|
657
|
+
/** Column names per `OWNER.TABLE`, for result-source resolution. */
|
|
658
|
+
columns = /* @__PURE__ */ new Map();
|
|
659
|
+
async init() {
|
|
660
|
+
for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
|
|
661
|
+
}
|
|
662
|
+
async tableColumns(schema, table) {
|
|
663
|
+
const key = `${schema}.${table}`;
|
|
664
|
+
const cached = this.columns.get(key);
|
|
665
|
+
if (cached !== void 0) return cached;
|
|
666
|
+
let out = null;
|
|
667
|
+
try {
|
|
668
|
+
const res = await this.conn.execute(
|
|
669
|
+
"SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
|
|
670
|
+
{ o: schema, t: table },
|
|
671
|
+
{ outFormat: oracledb.OUT_FORMAT_ARRAY }
|
|
672
|
+
);
|
|
673
|
+
out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
|
|
674
|
+
} catch {
|
|
675
|
+
out = null;
|
|
676
|
+
}
|
|
677
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
678
|
+
this.columns.set(key, out);
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
async query(sql2, database) {
|
|
682
|
+
if (database && database !== this.currentSchema) {
|
|
683
|
+
await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
|
|
684
|
+
this.currentSchema = database;
|
|
685
|
+
}
|
|
686
|
+
const text = normalizeStatement(sql2);
|
|
687
|
+
if (START_TX.test(text)) this.manualTx = true;
|
|
688
|
+
const res = await this.conn.execute(text, [], {
|
|
689
|
+
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
690
|
+
fetchTypeHandler,
|
|
691
|
+
autoCommit: !this.manualTx
|
|
692
|
+
});
|
|
693
|
+
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
694
|
+
if (END_TX.test(text)) this.manualTx = false;
|
|
695
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
696
|
+
const source = singleSourceTable(text);
|
|
697
|
+
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
698
|
+
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
699
|
+
const fields = res.metaData.map((col) => {
|
|
700
|
+
const fromTable = known?.has(col.name) ?? false;
|
|
701
|
+
return {
|
|
702
|
+
name: col.name,
|
|
703
|
+
orgName: col.name,
|
|
704
|
+
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
705
|
+
table: fromTable ? source.table : "",
|
|
706
|
+
orgTable: fromTable ? source.table : "",
|
|
707
|
+
db: fromTable ? schema : "",
|
|
708
|
+
maxLength: col.byteSize ?? 0
|
|
709
|
+
};
|
|
710
|
+
});
|
|
711
|
+
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
712
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
713
|
+
}
|
|
714
|
+
return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
|
|
715
|
+
}
|
|
716
|
+
async close() {
|
|
717
|
+
await this.conn.close();
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
function connectionAttributes(config) {
|
|
721
|
+
return {
|
|
722
|
+
user: config.user,
|
|
723
|
+
password: config.password,
|
|
724
|
+
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
var oracleDriverFactory = {
|
|
728
|
+
async open(config) {
|
|
729
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
730
|
+
const driver = new OracleDriver(conn, config);
|
|
731
|
+
await driver.init();
|
|
732
|
+
return driver;
|
|
733
|
+
},
|
|
734
|
+
async test(config) {
|
|
735
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
736
|
+
await conn.close();
|
|
737
|
+
},
|
|
738
|
+
parseConfig(body) {
|
|
739
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
740
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
741
|
+
if (!host || !user) {
|
|
742
|
+
throw new Error("host and user are required");
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
driver: "oracle",
|
|
746
|
+
host,
|
|
747
|
+
port: Number(body.port) || 1521,
|
|
748
|
+
user,
|
|
749
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
750
|
+
serviceName: typeof body.serviceName === "string" && body.serviceName ? body.serviceName : "FREEPDB1"
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// src/drivers/d1.ts
|
|
756
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
757
|
+
var D1Error = class extends Error {
|
|
758
|
+
constructor(message, status) {
|
|
759
|
+
super(message);
|
|
760
|
+
this.status = status;
|
|
761
|
+
}
|
|
762
|
+
status;
|
|
763
|
+
};
|
|
764
|
+
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
765
|
+
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
766
|
+
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
767
|
+
function singleSourceTable2(sql2) {
|
|
768
|
+
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
769
|
+
if (!m) return null;
|
|
770
|
+
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
771
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
772
|
+
const raw = m[1];
|
|
773
|
+
const first = raw[0];
|
|
774
|
+
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
775
|
+
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
776
|
+
if (first === "[") return raw.slice(1, -1);
|
|
777
|
+
return raw;
|
|
778
|
+
}
|
|
779
|
+
function normalizeDeclaredType2(declared) {
|
|
780
|
+
if (typeof declared !== "string") return null;
|
|
781
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
782
|
+
return base || null;
|
|
783
|
+
}
|
|
784
|
+
function inferTypeFromValue2(value) {
|
|
785
|
+
if (value === null || value === void 0) return "null";
|
|
786
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
787
|
+
if (Array.isArray(value)) return "blob";
|
|
788
|
+
return "text";
|
|
789
|
+
}
|
|
790
|
+
function firstNonNull2(records, index) {
|
|
791
|
+
for (const row of records) {
|
|
792
|
+
const value = row[index];
|
|
793
|
+
if (value !== null && value !== void 0) return value;
|
|
794
|
+
}
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
var D1Driver = class {
|
|
798
|
+
type = "d1";
|
|
799
|
+
label;
|
|
800
|
+
url;
|
|
801
|
+
headers;
|
|
802
|
+
/** Declared column types per table, for result-source resolution. */
|
|
803
|
+
columns = /* @__PURE__ */ new Map();
|
|
804
|
+
constructor(config) {
|
|
805
|
+
this.label = `d1:${config.databaseId}`;
|
|
806
|
+
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
807
|
+
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
808
|
+
}
|
|
809
|
+
async raw(sql2) {
|
|
810
|
+
let res;
|
|
811
|
+
try {
|
|
812
|
+
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }) });
|
|
813
|
+
} catch (err) {
|
|
814
|
+
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
815
|
+
}
|
|
816
|
+
let body = null;
|
|
817
|
+
try {
|
|
818
|
+
body = await res.json();
|
|
819
|
+
} catch {
|
|
820
|
+
}
|
|
821
|
+
if (!res.ok || !body?.success) {
|
|
822
|
+
const message = body?.errors?.map((e) => e.message).join("; ") || `Cloudflare API responded ${res.status}`;
|
|
823
|
+
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
824
|
+
}
|
|
825
|
+
const result = body.result?.[0];
|
|
826
|
+
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
827
|
+
return result;
|
|
828
|
+
}
|
|
829
|
+
async tableColumns(table) {
|
|
830
|
+
const cached = this.columns.get(table);
|
|
831
|
+
if (cached !== void 0) return cached;
|
|
832
|
+
let out = null;
|
|
833
|
+
try {
|
|
834
|
+
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
835
|
+
const cols = info.results?.columns ?? [];
|
|
836
|
+
const name = cols.indexOf("name");
|
|
837
|
+
const type = cols.indexOf("type");
|
|
838
|
+
const rows = info.results?.rows ?? [];
|
|
839
|
+
if (rows.length > 0 && name >= 0) {
|
|
840
|
+
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
|
|
841
|
+
}
|
|
842
|
+
} catch {
|
|
843
|
+
out = null;
|
|
844
|
+
}
|
|
845
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
846
|
+
this.columns.set(table, out);
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
async query(sql2) {
|
|
850
|
+
if (DATABASE_LIST.test(sql2)) {
|
|
851
|
+
return {
|
|
852
|
+
fields: [
|
|
853
|
+
{ name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
854
|
+
{ name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
855
|
+
{ name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
|
|
856
|
+
],
|
|
857
|
+
records: [[0, "main", ""]],
|
|
858
|
+
affectedRows: 0,
|
|
859
|
+
insertId: 0,
|
|
860
|
+
isResultQuery: true
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
864
|
+
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
865
|
+
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
866
|
+
}
|
|
867
|
+
if (this.isDdl(text)) this.columns.clear();
|
|
868
|
+
const result = await this.raw(text);
|
|
869
|
+
const columns = result.results?.columns ?? [];
|
|
870
|
+
const records = result.results?.rows ?? [];
|
|
871
|
+
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
872
|
+
return {
|
|
873
|
+
fields: [],
|
|
874
|
+
records: [],
|
|
875
|
+
affectedRows: result.meta?.changes ?? 0,
|
|
876
|
+
insertId: result.meta?.last_row_id ?? 0,
|
|
877
|
+
isResultQuery: false
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
881
|
+
const known = source ? await this.tableColumns(source) : null;
|
|
882
|
+
const fields = columns.map((name, index) => {
|
|
883
|
+
const fromTable = known?.has(name) ?? false;
|
|
884
|
+
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
885
|
+
return {
|
|
886
|
+
name,
|
|
887
|
+
orgName: name,
|
|
888
|
+
type,
|
|
889
|
+
table: fromTable && source ? source : "",
|
|
890
|
+
orgTable: fromTable && source ? source : "",
|
|
891
|
+
db: fromTable ? "main" : "",
|
|
892
|
+
maxLength: 0
|
|
893
|
+
};
|
|
894
|
+
});
|
|
895
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
896
|
+
}
|
|
897
|
+
isDdl(sql2) {
|
|
898
|
+
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
899
|
+
}
|
|
900
|
+
async close() {
|
|
901
|
+
this.columns.clear();
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
var d1DriverFactory = {
|
|
905
|
+
async open(config) {
|
|
906
|
+
const driver = new D1Driver(config);
|
|
907
|
+
await driver.raw("SELECT 1");
|
|
908
|
+
return driver;
|
|
909
|
+
},
|
|
910
|
+
async test(config) {
|
|
911
|
+
await new D1Driver(config).raw("SELECT 1");
|
|
912
|
+
},
|
|
913
|
+
parseConfig(body) {
|
|
914
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
915
|
+
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
916
|
+
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
917
|
+
if (!accountId || !databaseId || !apiToken) {
|
|
918
|
+
throw new Error("accountId, databaseId and apiToken are required");
|
|
919
|
+
}
|
|
920
|
+
return { driver: "d1", accountId, databaseId, apiToken };
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
|
|
469
924
|
// src/ssh-tunnel.ts
|
|
470
925
|
import { createServer } from "net";
|
|
471
926
|
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
@@ -811,7 +1266,10 @@ async function openSshTunnel(requested, target) {
|
|
|
811
1266
|
var factories = {
|
|
812
1267
|
mysql: mysqlDriverFactory,
|
|
813
1268
|
postgres: postgresDriverFactory,
|
|
814
|
-
sqlite: sqliteDriverFactory
|
|
1269
|
+
sqlite: sqliteDriverFactory,
|
|
1270
|
+
mssql: mssqlDriverFactory,
|
|
1271
|
+
oracle: oracleDriverFactory,
|
|
1272
|
+
d1: d1DriverFactory
|
|
815
1273
|
};
|
|
816
1274
|
function isDriverType(value) {
|
|
817
1275
|
return typeof value === "string" && value in factories;
|
|
@@ -822,12 +1280,12 @@ function parseDriverConfig(body) {
|
|
|
822
1280
|
throw new Error(`Unsupported driver: ${String(driver)}`);
|
|
823
1281
|
}
|
|
824
1282
|
const config = factories[driver].parseConfig(body);
|
|
825
|
-
if (config.driver === "sqlite") return config;
|
|
1283
|
+
if (config.driver === "sqlite" || config.driver === "d1") return config;
|
|
826
1284
|
const ssh = parseSshConfig(body.ssh);
|
|
827
1285
|
return ssh ? { ...config, ssh } : config;
|
|
828
1286
|
}
|
|
829
1287
|
async function withTunnel(config, run) {
|
|
830
|
-
if (config.driver === "sqlite" || !config.ssh) return run(config, void 0);
|
|
1288
|
+
if (config.driver === "sqlite" || config.driver === "d1" || !config.ssh) return run(config, void 0);
|
|
831
1289
|
const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
|
|
832
1290
|
try {
|
|
833
1291
|
return await run({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
|
|
@@ -840,7 +1298,7 @@ function tunneled(driver, tunnel, label) {
|
|
|
840
1298
|
return {
|
|
841
1299
|
type: driver.type,
|
|
842
1300
|
label,
|
|
843
|
-
query: (
|
|
1301
|
+
query: (sql2, database) => driver.query(sql2, database),
|
|
844
1302
|
async close() {
|
|
845
1303
|
try {
|
|
846
1304
|
await driver.close();
|
|
@@ -853,7 +1311,7 @@ function tunneled(driver, tunnel, label) {
|
|
|
853
1311
|
function openDriver(config) {
|
|
854
1312
|
return withTunnel(config, async (direct, tunnel) => {
|
|
855
1313
|
const driver = await openDirect(direct);
|
|
856
|
-
if (!tunnel || config.driver === "sqlite" || !config.ssh) return driver;
|
|
1314
|
+
if (!tunnel || config.driver === "sqlite" || config.driver === "d1" || !config.ssh) return driver;
|
|
857
1315
|
const label = `${config.user}@${config.host}:${config.port} via ssh ${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`;
|
|
858
1316
|
return tunneled(driver, tunnel, label);
|
|
859
1317
|
});
|
|
@@ -875,6 +1333,12 @@ function openDirect(config) {
|
|
|
875
1333
|
return factories.postgres.open(config);
|
|
876
1334
|
case "sqlite":
|
|
877
1335
|
return factories.sqlite.open(config);
|
|
1336
|
+
case "mssql":
|
|
1337
|
+
return factories.mssql.open(config);
|
|
1338
|
+
case "oracle":
|
|
1339
|
+
return factories.oracle.open(config);
|
|
1340
|
+
case "d1":
|
|
1341
|
+
return factories.d1.open(config);
|
|
878
1342
|
}
|
|
879
1343
|
}
|
|
880
1344
|
function testDirect(config) {
|
|
@@ -885,6 +1349,12 @@ function testDirect(config) {
|
|
|
885
1349
|
return factories.postgres.test(config);
|
|
886
1350
|
case "sqlite":
|
|
887
1351
|
return factories.sqlite.test(config);
|
|
1352
|
+
case "mssql":
|
|
1353
|
+
return factories.mssql.test(config);
|
|
1354
|
+
case "oracle":
|
|
1355
|
+
return factories.oracle.test(config);
|
|
1356
|
+
case "d1":
|
|
1357
|
+
return factories.d1.test(config);
|
|
888
1358
|
}
|
|
889
1359
|
}
|
|
890
1360
|
|
|
@@ -925,13 +1395,13 @@ var ConnectionManager = class {
|
|
|
925
1395
|
* Execute a SQL query on an existing connection.
|
|
926
1396
|
* Optionally switch database before executing (drivers that support it).
|
|
927
1397
|
*/
|
|
928
|
-
async query(connectionId,
|
|
1398
|
+
async query(connectionId, sql2, database) {
|
|
929
1399
|
const driver = this.connections.get(connectionId);
|
|
930
1400
|
if (!driver) {
|
|
931
1401
|
throw new Error(`Connection not found: ${connectionId}`);
|
|
932
1402
|
}
|
|
933
1403
|
this.lastUsed.set(connectionId, Date.now());
|
|
934
|
-
return driver.query(
|
|
1404
|
+
return driver.query(sql2, database);
|
|
935
1405
|
}
|
|
936
1406
|
/**
|
|
937
1407
|
* Close and remove a connection.
|
|
@@ -1073,8 +1543,8 @@ function createAgent(options) {
|
|
|
1073
1543
|
});
|
|
1074
1544
|
app.post("/query", async (req, res) => {
|
|
1075
1545
|
try {
|
|
1076
|
-
const { connectionId, sql, database } = req.body;
|
|
1077
|
-
if (typeof connectionId !== "string" || typeof
|
|
1546
|
+
const { connectionId, sql: sql2, database } = req.body;
|
|
1547
|
+
if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
|
|
1078
1548
|
fail(res, 400, "connectionId and sql are required");
|
|
1079
1549
|
return;
|
|
1080
1550
|
}
|
|
@@ -1083,7 +1553,7 @@ function createAgent(options) {
|
|
|
1083
1553
|
return;
|
|
1084
1554
|
}
|
|
1085
1555
|
const start = Date.now();
|
|
1086
|
-
const result = await manager.query(connectionId,
|
|
1556
|
+
const result = await manager.query(connectionId, sql2, database);
|
|
1087
1557
|
const execTime = Date.now() - start;
|
|
1088
1558
|
res.json({
|
|
1089
1559
|
success: true,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planche",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite databases from your machine.",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite/SQL Server/Oracle/Neon/Supabase/Cloudflare D1 databases from your machine.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://planche.dev",
|
|
7
7
|
"repository": {
|
|
@@ -28,6 +28,13 @@
|
|
|
28
28
|
"postgres",
|
|
29
29
|
"postgresql",
|
|
30
30
|
"sqlite",
|
|
31
|
+
"mssql",
|
|
32
|
+
"sqlserver",
|
|
33
|
+
"oracle",
|
|
34
|
+
"neon",
|
|
35
|
+
"supabase",
|
|
36
|
+
"cloudflare-d1",
|
|
37
|
+
"d1",
|
|
31
38
|
"sql",
|
|
32
39
|
"client",
|
|
33
40
|
"gui"
|
|
@@ -42,14 +49,18 @@
|
|
|
42
49
|
"dependencies": {
|
|
43
50
|
"better-sqlite3": "^12.11.1",
|
|
44
51
|
"express": "^5.1.0",
|
|
52
|
+
"mssql": "^12.7.0",
|
|
45
53
|
"mysql2": "^3.14.1",
|
|
54
|
+
"oracledb": "^7.0.1",
|
|
46
55
|
"pg": "^8.23.0",
|
|
47
56
|
"ssh2": "^1.17.0"
|
|
48
57
|
},
|
|
49
58
|
"devDependencies": {
|
|
50
59
|
"@types/better-sqlite3": "^9.6.0",
|
|
51
60
|
"@types/express": "^5.0.0",
|
|
61
|
+
"@types/mssql": "^12.3.0",
|
|
52
62
|
"@types/node": "^24.0.0",
|
|
63
|
+
"@types/oracledb": "^7.0.2",
|
|
53
64
|
"@types/pg": "^8.23.1",
|
|
54
65
|
"@types/ssh2": "^1.15.5",
|
|
55
66
|
"tsup": "^8.5.0",
|