planche 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +9 -2
  2. package/dist/index.js +311 -16
  3. package/package.json +9 -2
package/README.md CHANGED
@@ -1,7 +1,7 @@
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 SQLite.
4
+ MySQL, MariaDB, PostgreSQL, SQLite, SQL Server and Oracle.
5
5
 
6
6
  Browsers cannot open TCP connections to a database, so Planche runs this small
7
7
  agent on your machine. The app in your browser talks to the agent over
@@ -90,7 +90,14 @@ database connections open and forwards queries with the matching driver
90
90
  - **SQLite** — opens a file on your disk by path. Set `PLANCHE_SQLITE_DIR` to
91
91
  limit which files can be opened. (Small SQLite files can also be opened
92
92
  directly in the browser without the agent.)
93
- - **SSH tunnel** — MySQL and PostgreSQL profiles can go through an SSH jump
93
+ - **SQL Server** — same model as PostgreSQL: the connection is bound to one
94
+ database and the app's "database" is a schema (`dbo`, ...). `USE` works in
95
+ the editor because every statement runs on the same session; scripts may
96
+ contain `GO` lines.
97
+ - **Oracle** — thin-mode driver, no Instant Client needed. Schemas (users) are
98
+ the app's "databases", switched with `ALTER SESSION SET CURRENT_SCHEMA`.
99
+ PL/SQL blocks end with a `/` line as in SQL*Plus.
100
+ - **SSH tunnel** — MySQL, PostgreSQL, SQL Server and Oracle profiles can go through an SSH jump
94
101
  host (password, private key, or the local `ssh-agent`). The agent opens the
95
102
  tunnel with `ssh2` on a loopback port and points the driver at it. The host
96
103
  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(sql, database) {
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(sql);
89
- if (USE_STATEMENT.test(sql)) this.currentDatabase = null;
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) => ({
@@ -269,13 +269,13 @@ var PostgresDriver = class {
269
269
  } catch {
270
270
  }
271
271
  }
272
- async query(sql, database) {
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: sql, rowMode: "array" });
278
- if (SEARCH_PATH_STATEMENT.test(sql)) this.currentSchema = null;
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) {
@@ -385,13 +385,13 @@ var SqliteDriver = class {
385
385
  db;
386
386
  type = "sqlite";
387
387
  label;
388
- async query(sql) {
388
+ async query(sql2) {
389
389
  let statement;
390
390
  try {
391
- statement = this.db.prepare(sql);
391
+ statement = this.db.prepare(sql2);
392
392
  } catch (err) {
393
393
  if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
394
- this.db.exec(sql);
394
+ this.db.exec(sql2);
395
395
  return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
396
396
  }
397
397
  throw err;
@@ -466,6 +466,291 @@ var sqliteDriverFactory = {
466
466
  }
467
467
  };
468
468
 
469
+ // src/drivers/mssql.ts
470
+ import sql from "mssql";
471
+ function toPoolConfig(config) {
472
+ return {
473
+ server: config.host,
474
+ port: config.port,
475
+ user: config.user,
476
+ password: config.password,
477
+ database: config.database,
478
+ // One physical connection: manual transactions and USE need session affinity.
479
+ pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
480
+ options: {
481
+ encrypt: config.ssl === true,
482
+ trustServerCertificate: true,
483
+ // Keep date/time values as sent by the server rather than shifting to UTC.
484
+ useUTC: false,
485
+ enableArithAbort: true
486
+ },
487
+ requestTimeout: 0
488
+ };
489
+ }
490
+ var pad = (n, width = 2) => String(n).padStart(width, "0");
491
+ function formatDate(d) {
492
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
493
+ const ms = d.getMilliseconds();
494
+ return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
495
+ }
496
+ function normalizeValue(value) {
497
+ if (value instanceof Date) return formatDate(value);
498
+ if (Buffer.isBuffer(value)) return value.toString("hex");
499
+ return value;
500
+ }
501
+ var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
502
+ var MssqlDriver = class {
503
+ constructor(pool, config) {
504
+ this.pool = pool;
505
+ this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
506
+ }
507
+ pool;
508
+ type = "mssql";
509
+ label;
510
+ /** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
511
+ sources = /* @__PURE__ */ new Map();
512
+ /**
513
+ * The `mssql` driver does not report which table a result column comes
514
+ * from; ask the server to describe the statement instead so the grid can
515
+ * decide whether it is editable and address rows by primary key.
516
+ */
517
+ async describe(text) {
518
+ const cached = this.sources.get(text);
519
+ if (cached !== void 0) return cached;
520
+ let result = null;
521
+ try {
522
+ const req = this.pool.request();
523
+ req.input("sql", sql.NVarChar(sql.MAX), text);
524
+ const res = await req.query(
525
+ "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"
526
+ );
527
+ result = res.recordset.map((r) => ({
528
+ schema: r.source_schema ?? "",
529
+ table: r.source_table ?? "",
530
+ column: r.source_column ?? "",
531
+ type: (r.system_type_name ?? "").toLowerCase()
532
+ }));
533
+ } catch {
534
+ result = null;
535
+ }
536
+ if (this.sources.size > 200) this.sources.clear();
537
+ this.sources.set(text, result);
538
+ return result;
539
+ }
540
+ async query(text) {
541
+ const req = this.pool.request();
542
+ req.arrayRowMode = true;
543
+ const res = await req.query(text);
544
+ const sets = res.recordsets;
545
+ const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
546
+ const columns = last?.columns;
547
+ if (last && columns) {
548
+ const meta = Object.values(columns).sort((a, b) => a.index - b.index);
549
+ const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
550
+ const fields = meta.map((col, i) => {
551
+ const src = sources && sources.length === meta.length ? sources[i] : void 0;
552
+ const typeName = col.type?.declaration ?? "";
553
+ return {
554
+ name: col.name,
555
+ orgName: src?.column || col.name,
556
+ type: src?.type || typeName.toLowerCase(),
557
+ table: src?.table ?? "",
558
+ orgTable: src?.table ?? "",
559
+ db: src?.schema ?? "",
560
+ maxLength: typeof col.length === "number" ? col.length : 0
561
+ };
562
+ });
563
+ const records = last.map((row) => row.map(normalizeValue));
564
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
565
+ }
566
+ return {
567
+ fields: [],
568
+ records: [],
569
+ affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
570
+ insertId: 0,
571
+ isResultQuery: false
572
+ };
573
+ }
574
+ async close() {
575
+ await this.pool.close();
576
+ }
577
+ };
578
+ var mssqlDriverFactory = {
579
+ async open(config) {
580
+ const pool = new sql.ConnectionPool(toPoolConfig(config));
581
+ await pool.connect();
582
+ return new MssqlDriver(pool, config);
583
+ },
584
+ async test(config) {
585
+ const pool = new sql.ConnectionPool(toPoolConfig(config));
586
+ await pool.connect();
587
+ await pool.close();
588
+ },
589
+ parseConfig(body) {
590
+ const host = typeof body.host === "string" ? body.host.trim() : "";
591
+ const user = typeof body.user === "string" ? body.user : "";
592
+ if (!host || !user) {
593
+ throw new Error("host and user are required");
594
+ }
595
+ return {
596
+ driver: "mssql",
597
+ host,
598
+ port: Number(body.port) || 1433,
599
+ user,
600
+ password: typeof body.password === "string" ? body.password : "",
601
+ database: typeof body.database === "string" && body.database ? body.database : "master",
602
+ ssl: body.ssl === true
603
+ };
604
+ }
605
+ };
606
+
607
+ // src/drivers/oracle.ts
608
+ import oracledb from "oracledb";
609
+ oracledb.autoCommit = true;
610
+ oracledb.fetchAsString = [oracledb.CLOB];
611
+ var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
612
+ var pad2 = (n, width = 2) => String(n).padStart(width, "0");
613
+ function formatDate2(d) {
614
+ const ms = d.getMilliseconds();
615
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
616
+ }
617
+ function normalizeValue2(value) {
618
+ if (value instanceof Date) return formatDate2(value);
619
+ if (Buffer.isBuffer(value)) return value.toString("hex");
620
+ return value;
621
+ }
622
+ var SESSION_FORMATS = [
623
+ "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
624
+ "ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
625
+ "ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
626
+ ];
627
+ var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
628
+ var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
629
+ function normalizeStatement(sql2) {
630
+ const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
631
+ if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
632
+ return trimmed.replace(/;\s*$/, "");
633
+ }
634
+ var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
635
+ var START_TX = /^\s*SET\s+TRANSACTION\b/i;
636
+ var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
637
+ function singleSourceTable(sql2) {
638
+ const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
639
+ if (!m) return null;
640
+ 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, "");
641
+ if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
642
+ const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
643
+ return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
644
+ }
645
+ var OracleDriver = class {
646
+ constructor(conn, config) {
647
+ this.conn = conn;
648
+ this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
649
+ this.currentSchema = config.user.toUpperCase();
650
+ }
651
+ conn;
652
+ type = "oracle";
653
+ label;
654
+ currentSchema;
655
+ manualTx = false;
656
+ /** Column names per `OWNER.TABLE`, for result-source resolution. */
657
+ columns = /* @__PURE__ */ new Map();
658
+ async init() {
659
+ for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
660
+ }
661
+ async tableColumns(schema, table) {
662
+ const key = `${schema}.${table}`;
663
+ const cached = this.columns.get(key);
664
+ if (cached !== void 0) return cached;
665
+ let out = null;
666
+ try {
667
+ const res = await this.conn.execute(
668
+ "SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
669
+ { o: schema, t: table },
670
+ { outFormat: oracledb.OUT_FORMAT_ARRAY }
671
+ );
672
+ out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
673
+ } catch {
674
+ out = null;
675
+ }
676
+ if (this.columns.size > 200) this.columns.clear();
677
+ this.columns.set(key, out);
678
+ return out;
679
+ }
680
+ async query(sql2, database) {
681
+ if (database && database !== this.currentSchema) {
682
+ await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
683
+ this.currentSchema = database;
684
+ }
685
+ const text = normalizeStatement(sql2);
686
+ if (START_TX.test(text)) this.manualTx = true;
687
+ const res = await this.conn.execute(text, [], {
688
+ outFormat: oracledb.OUT_FORMAT_ARRAY,
689
+ fetchTypeHandler,
690
+ autoCommit: !this.manualTx
691
+ });
692
+ if (SET_SCHEMA.test(text)) this.currentSchema = null;
693
+ if (END_TX.test(text)) this.manualTx = false;
694
+ if (res.metaData && res.metaData.length > 0) {
695
+ const source = singleSourceTable(text);
696
+ const schema = source?.schema ?? this.currentSchema ?? "";
697
+ const known = source && schema ? await this.tableColumns(schema, source.table) : null;
698
+ const fields = res.metaData.map((col) => {
699
+ const fromTable = known?.has(col.name) ?? false;
700
+ return {
701
+ name: col.name,
702
+ orgName: col.name,
703
+ type: (col.dbTypeName ?? "").toLowerCase(),
704
+ table: fromTable ? source.table : "",
705
+ orgTable: fromTable ? source.table : "",
706
+ db: fromTable ? schema : "",
707
+ maxLength: col.byteSize ?? 0
708
+ };
709
+ });
710
+ const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
711
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
712
+ }
713
+ return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
714
+ }
715
+ async close() {
716
+ await this.conn.close();
717
+ }
718
+ };
719
+ function connectionAttributes(config) {
720
+ return {
721
+ user: config.user,
722
+ password: config.password,
723
+ connectString: `${config.host}:${config.port}/${config.serviceName}`
724
+ };
725
+ }
726
+ var oracleDriverFactory = {
727
+ async open(config) {
728
+ const conn = await oracledb.getConnection(connectionAttributes(config));
729
+ const driver = new OracleDriver(conn, config);
730
+ await driver.init();
731
+ return driver;
732
+ },
733
+ async test(config) {
734
+ const conn = await oracledb.getConnection(connectionAttributes(config));
735
+ await conn.close();
736
+ },
737
+ parseConfig(body) {
738
+ const host = typeof body.host === "string" ? body.host.trim() : "";
739
+ const user = typeof body.user === "string" ? body.user : "";
740
+ if (!host || !user) {
741
+ throw new Error("host and user are required");
742
+ }
743
+ return {
744
+ driver: "oracle",
745
+ host,
746
+ port: Number(body.port) || 1521,
747
+ user,
748
+ password: typeof body.password === "string" ? body.password : "",
749
+ serviceName: typeof body.serviceName === "string" && body.serviceName ? body.serviceName : "FREEPDB1"
750
+ };
751
+ }
752
+ };
753
+
469
754
  // src/ssh-tunnel.ts
470
755
  import { createServer } from "net";
471
756
  import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
@@ -811,7 +1096,9 @@ async function openSshTunnel(requested, target) {
811
1096
  var factories = {
812
1097
  mysql: mysqlDriverFactory,
813
1098
  postgres: postgresDriverFactory,
814
- sqlite: sqliteDriverFactory
1099
+ sqlite: sqliteDriverFactory,
1100
+ mssql: mssqlDriverFactory,
1101
+ oracle: oracleDriverFactory
815
1102
  };
816
1103
  function isDriverType(value) {
817
1104
  return typeof value === "string" && value in factories;
@@ -840,7 +1127,7 @@ function tunneled(driver, tunnel, label) {
840
1127
  return {
841
1128
  type: driver.type,
842
1129
  label,
843
- query: (sql, database) => driver.query(sql, database),
1130
+ query: (sql2, database) => driver.query(sql2, database),
844
1131
  async close() {
845
1132
  try {
846
1133
  await driver.close();
@@ -875,6 +1162,10 @@ function openDirect(config) {
875
1162
  return factories.postgres.open(config);
876
1163
  case "sqlite":
877
1164
  return factories.sqlite.open(config);
1165
+ case "mssql":
1166
+ return factories.mssql.open(config);
1167
+ case "oracle":
1168
+ return factories.oracle.open(config);
878
1169
  }
879
1170
  }
880
1171
  function testDirect(config) {
@@ -885,6 +1176,10 @@ function testDirect(config) {
885
1176
  return factories.postgres.test(config);
886
1177
  case "sqlite":
887
1178
  return factories.sqlite.test(config);
1179
+ case "mssql":
1180
+ return factories.mssql.test(config);
1181
+ case "oracle":
1182
+ return factories.oracle.test(config);
888
1183
  }
889
1184
  }
890
1185
 
@@ -925,13 +1220,13 @@ var ConnectionManager = class {
925
1220
  * Execute a SQL query on an existing connection.
926
1221
  * Optionally switch database before executing (drivers that support it).
927
1222
  */
928
- async query(connectionId, sql, database) {
1223
+ async query(connectionId, sql2, database) {
929
1224
  const driver = this.connections.get(connectionId);
930
1225
  if (!driver) {
931
1226
  throw new Error(`Connection not found: ${connectionId}`);
932
1227
  }
933
1228
  this.lastUsed.set(connectionId, Date.now());
934
- return driver.query(sql, database);
1229
+ return driver.query(sql2, database);
935
1230
  }
936
1231
  /**
937
1232
  * Close and remove a connection.
@@ -1073,8 +1368,8 @@ function createAgent(options) {
1073
1368
  });
1074
1369
  app.post("/query", async (req, res) => {
1075
1370
  try {
1076
- const { connectionId, sql, database } = req.body;
1077
- if (typeof connectionId !== "string" || typeof sql !== "string" || !connectionId || !sql) {
1371
+ const { connectionId, sql: sql2, database } = req.body;
1372
+ if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
1078
1373
  fail(res, 400, "connectionId and sql are required");
1079
1374
  return;
1080
1375
  }
@@ -1083,7 +1378,7 @@ function createAgent(options) {
1083
1378
  return;
1084
1379
  }
1085
1380
  const start = Date.now();
1086
- const result = await manager.query(connectionId, sql, database);
1381
+ const result = await manager.query(connectionId, sql2, database);
1087
1382
  const execTime = Date.now() - start;
1088
1383
  res.json({
1089
1384
  success: true,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "planche",
3
- "version": "0.2.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.3.0",
4
+ "description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite/SQL Server/Oracle databases from your machine.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://planche.dev",
7
7
  "repository": {
@@ -28,6 +28,9 @@
28
28
  "postgres",
29
29
  "postgresql",
30
30
  "sqlite",
31
+ "mssql",
32
+ "sqlserver",
33
+ "oracle",
31
34
  "sql",
32
35
  "client",
33
36
  "gui"
@@ -42,14 +45,18 @@
42
45
  "dependencies": {
43
46
  "better-sqlite3": "^12.11.1",
44
47
  "express": "^5.1.0",
48
+ "mssql": "^12.7.0",
45
49
  "mysql2": "^3.14.1",
50
+ "oracledb": "^7.0.1",
46
51
  "pg": "^8.23.0",
47
52
  "ssh2": "^1.17.0"
48
53
  },
49
54
  "devDependencies": {
50
55
  "@types/better-sqlite3": "^9.6.0",
51
56
  "@types/express": "^5.0.0",
57
+ "@types/mssql": "^12.3.0",
52
58
  "@types/node": "^24.0.0",
59
+ "@types/oracledb": "^7.0.2",
53
60
  "@types/pg": "^8.23.1",
54
61
  "@types/ssh2": "^1.15.5",
55
62
  "tsup": "^8.5.0",