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.
Files changed (3) hide show
  1. package/README.md +10 -2
  2. package/dist/index.js +491 -21
  3. 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 SQLite.
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
- - **SSH tunnel** — MySQL and PostgreSQL profiles can go through an SSH jump
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(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) => ({
@@ -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: false } : void 0,
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(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) {
@@ -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(sql) {
389
+ async query(sql2) {
389
390
  let statement;
390
391
  try {
391
- statement = this.db.prepare(sql);
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(sql);
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: (sql, database) => driver.query(sql, database),
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, sql, database) {
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(sql, database);
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 sql !== "string" || !connectionId || !sql) {
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, sql, database);
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.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.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",