Repository navigation
Expand file tree
/
Copy pathfactory.ts
More file actions
1853 lines (1711 loc) · 92.4 KB
/
Copy pathfactory.ts
File metadata and controls
1853 lines (1711 loc) · 92.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Database Provider Factory
* Creates appropriate provider instance based on connection type
* Uses dynamic imports to reduce memory footprint - providers are loaded on demand
*/
import {
type DatabaseProvider,
type DatabaseConnection,
type EditorExecutionContext,
type ProviderOptions,
type ProviderExecutionContext,
} from "./types";
import { connectPhase, DatabaseConfigError, ExecutionProfileError } from "./errors";
import { createSSHTunnel, closeSSHTunnel, hasTunnel } from "@/lib/ssh/tunnel";
import type { TunnelInfo } from "@/lib/ssh/tunnel";
import { readSecret } from "@/lib/storage/encryption";
import { providerCacheKey, providerOwner } from "./provider-cache-key";
import { registerOwnerRemoval } from "./owner-removal";
import { READ_ONLY_ENFORCED, READS_FILE_ACCESS_POSTURE, SHIPPED_DATABASE_TYPES } from "./compatibility";
import { TUNNEL_FAR_END, type DatabaseType, type WithTunnelFarEnd } from "@/lib/types";
import { logger } from "@/lib/logger";
import { runShutdownHooks } from "@/lib/shutdown";
import * as path from "path";
// ============================================================================
// Provider Factory
// ============================================================================
/**
* Create a database provider based on connection configuration
* Uses dynamic imports to load providers on-demand, reducing initial memory usage
*
* @param connection - Database connection configuration
* @param options - Optional provider options (pooling, timeout, etc.)
* @param execution - Server-injected execution context (#328, non-admin DuckDB file access). Never built
* from caller-supplied options. `acquireExecutionProfileProvider` passes the agent
* profile's context as `executionContextFor` builds it, and `getOrCreateProvider` and the routes that build their own
* provider pass the editor file-access posture `editorExecutionContext` derived from
* the verified session and the resolved connection. What each provider establishes with `readOnly` and
* `sendsStatements` is stated once, on `ProviderExecutionContext` (types.ts); DuckDB reads
* `allowExternalFileAccess` on its editor handle, and SQLite refuses to open at all when it is
* `false`, a profiled handle included. On DuckDB, ABSENT MEANS DENY: called without a context, as an
* embedder that predates the posture calls it, a DuckDB handle opens with
* `enable_external_access: 'false'`, so `read_csv`, `COPY`, `ATTACH` of a file and
* `INSTALL` are refused outside its private temp directory and its database's own file
* names (docs/providers/duckdb.md section 3.16) while the database stays writable; pass
* `{ allowExternalFileAccess: true }` for the full editor reach. SQLite refuses only an explicit
* `false`, so called without a context it opens with the trusted reach. An `owner` makes the provider
* that user's own (docs/GOVERNANCE.md#per-user-providers): its pool opens with `min: 0` and `max: ownerPoolMax`.
* @returns Promise<DatabaseProvider> instance
* @throws DatabaseConfigError if connection type is not supported or is left out of a set
* `LIBREDB_ENABLED_PROVIDERS` (see assertProviderEnabled), if its `readOnly` cannot be
* honoured (see assertReadOnlyHonoured), or if an owner comes without a whole `ownerPoolMax`
* from 1 (see assertOwnerPoolHonoured)
*
* @example
* // SQL Database
* const provider = await createDatabaseProvider({
* id: '1',
* name: 'My PostgreSQL',
* type: 'postgres',
* host: 'localhost',
* port: 5432,
* database: 'mydb',
* user: 'admin',
* password: 'secret',
* createdAt: new Date(),
* });
*
* // MongoDB
* const mongoProvider = await createDatabaseProvider({
* id: '2',
* name: 'My MongoDB',
* type: 'mongodb',
* connectionString: 'mongodb://localhost:27017/mydb',
* createdAt: new Date(),
* });
*
* await provider.connect();
* const result = await provider.query('SELECT * FROM users');
* await provider.disconnect();
*/
/**
* Strip the control characters a log line's framing is made of, so a caller-supplied value
* cannot write a line of its own.
*
* Hoisted out of `createDatabaseProvider`, where it guarded `type` and `name`, once
* `connection.id` was established to be caller-supplied too (GHSA-3wh2-8x78-jfw4 - see
* {@link providerCacheKey}). Every site below that INTERPOLATES an id into a message uses it;
* the ids passed as structured logger FIELDS do not need it, because a field is never parsed
* as part of the line.
*/
const sanitize = (v: string) => v.replace(/[\r\n]/g, " ").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
/**
* Refuse a connection with no id, before the cache key is computed (#1539).
*
* The provider's own `validate()` raises `DatabaseConfigError("Connection ID is required")` for this
* record, but `getOrCreateProvider` and `acquireExecutionProfileProvider` compute their cache key
* first, and `providerCacheKey` length-frames `connection.id`: a missing id crashed there with a
* `TypeError`, which `createErrorResponse` answered as a 500 `INTERNAL_ERROR` on every route that
* opens a cached provider (query, health and multi-query were measured). The same record reaching the
* provider through `createDatabaseProvider`, as `POST /api/db/test-connection` builds it, was already
* refused with a 400 `CONFIG_ERROR`. The refusal is raised here instead, as the same
* `DatabaseConfigError` the provider would have raised, so the key never sees a record the provider
* would refuse, and no fallback key is invented for a missing id.
*/
function assertConnectionIdPresent(connection: DatabaseConnection): void {
if (!connection.id) {
throw new DatabaseConfigError("Connection ID is required", connection.type);
}
}
/**
* Refuse a `readOnly` this connection's engine cannot keep, before anything is built or dialled (#1089).
*
* A read-only connection is a promise its provider keeps by refusing every write before any request,
* so it is accepted only on an engine `READ_ONLY_ENFORCED` names. Anywhere else the connection would
* open able to write under a mode that says it cannot, which is refused rather than ignored. A value
* that is not a boolean is refused on every engine, because a string read as true on one path and as
* absent on another is two modes. The message names the field and never echoes the value.
*
* It reads the static map rather than `getCapabilities()` because it runs before a provider exists,
* and `tests/unit/db/read-only-enforced-capability.test.ts` holds the map equal to every provider's
* `enforcesReadOnly`.
*
* WHERE IT RUNS. The first statement of `createDatabaseProvider` and `withOneShotTunnel`, and of
* `getOrCreateProvider` and `acquireExecutionProfileProvider`, ahead of their cache lookup and of
* `createSSHTunnel`: those two open the tunnel before they call `createDatabaseProvider`, outside the
* try that closes a fresh one, so a refusal raised only inside the factory would dial the bastion and
* leave the pooled tunnel open. Ahead of the lookup too, because a mode that is not a boolean keys as
* `read-write` (`providerCacheKey`), so the lookup would hand it the connection's cached read-write
* provider without refusing it. `assertProviderEnabled` stands beside it in all four places for the same
* reason: a type `LIBREDB_ENABLED_PROVIDERS` leaves out must never reach a cache lookup or dial a bastion.
* Exported for the tests; the published factory's three entry points raise it (`src/exports/providers.ts`).
*/
export function assertReadOnlyHonoured(connection: DatabaseConnection): void {
const readOnly: unknown = connection.readOnly;
if (readOnly === undefined) return;
if (typeof readOnly !== "boolean") {
throw new DatabaseConfigError("readOnly must be true or false.", connection.type);
}
if (readOnly && READ_ONLY_ENFORCED[connection.type] !== true) {
throw new DatabaseConfigError(
`readOnly: true is refused for ${sanitize(connection.type)}: its provider does not enforce a read-only mode, so the connection would open able to write. Remove readOnly from the connection, or connect with a database role that cannot write.`,
connection.type,
);
}
}
/**
* Refuse an owner without a pool size its provider can open with (docs/GOVERNANCE.md#per-user-providers), before
* anything is built or dialled.
*
* An owned provider opens its pool with `min: 0` and `max: ownerPoolMax`, so the size must be a whole number from 1.
* pg would refuse nothing else: it opens its default of 10 for 0, NaN or no size at all, and a pool with a negative
* size never hands out a connection, so the owner's share would be lifted, or the provider wedged, without a word.
* It runs where `assertReadOnlyHonoured` runs and for the reason that function gives: `getOrCreateProvider` and
* `acquireExecutionProfileProvider` open the SSH tunnel before they build the provider. `editorExecutionContext`
* always sets the owner and the size together, so the refusal reaches only a context built elsewhere.
*/
function assertOwnerPoolHonoured(connection: DatabaseConnection, execution: ProviderExecutionContext): void {
if (execution.owner === undefined) return;
const max: unknown = execution.ownerPoolMax;
if (typeof max !== "number" || !Number.isInteger(max) || max < 1) {
throw new DatabaseConfigError(
"ownerPoolMax must be a whole number from 1 when an owner is given: it is the most connections the owner's provider may open.",
connection.type,
);
}
}
// ============================================================================
// Enabled providers (LIBREDB_ENABLED_PROVIDERS)
// ============================================================================
/** Every connection type id, the embedded LibreDB store included: the exhaustive shipped set. */
const CONNECTION_TYPE_IDS: ReadonlySet<string> = new Set<string>(SHIPPED_DATABASE_TYPES);
/**
* The connection types this server may open: `LIBREDB_ENABLED_PROVIDERS` as a comma list, trimmed, with empty
* entries dropped and each type kept once in first-seen order, or `null` when the variable is unset or blank, which
* enables every provider. The VS Code extension is the one channel that sets it, in its backend process; the web app,
* Docker, Helm, npx and the desktop launchers leave it unset. Read on every call, never cached.
*
* @throws DatabaseConfigError naming the entry when an entry is not a connection type id, or when a set value lists none.
*/
export function enabledProviderTypes(): readonly DatabaseType[] | null {
const raw = process.env.LIBREDB_ENABLED_PROVIDERS;
if (raw === undefined || raw.trim() === "") return null;
const entries = [
...new Set(
raw
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry !== ""),
),
];
if (entries.length === 0) {
throw new DatabaseConfigError(
"LIBREDB_ENABLED_PROVIDERS is set but lists no connection type id. Leave it unset to enable every provider.",
);
}
const unknown = entries.find((entry) => !CONNECTION_TYPE_IDS.has(entry));
if (unknown !== undefined) {
throw new DatabaseConfigError(
`LIBREDB_ENABLED_PROVIDERS names ${JSON.stringify(unknown)}, which is not a connection type id. List type ids such as postgres, separated by commas.`,
);
}
return entries as DatabaseType[];
}
/**
* Refuse a connection whose type `LIBREDB_ENABLED_PROVIDERS` leaves out, before anything is built, loaded or dialled.
*
* It is set membership inside the provider layer and names no type id, so `tests/unit/lib/no-engine-branches.test.ts`
* needs no entry for it. It runs before the `switch` of `createDatabaseProvider`, so the provider module of a refused
* type, and its driver, is never imported. Unset or blank returns at once; a value that names something other than a
* type id raises the reader's `DatabaseConfigError`, so every connection fails with that entry named.
*/
function assertProviderEnabled(connection: DatabaseConnection): void {
const enabled = enabledProviderTypes();
if (enabled === null || enabled.includes(connection.type)) return;
throw new DatabaseConfigError(
`The ${sanitize(connection.type)} provider is not available in this distribution of LibreDB Studio.`,
connection.type,
);
}
export async function createDatabaseProvider(
connection: DatabaseConnection,
options: ProviderOptions = {},
execution: ProviderExecutionContext = {},
): Promise<DatabaseProvider> {
assertProviderEnabled(connection);
assertReadOnlyHonoured(connection);
assertOwnerPoolHonoured(connection, execution);
console.log(`[DB] Creating ${sanitize(connection.type)} provider for "${sanitize(connection.name || "")}"`);
// Explicit overrides (such as the connectivity probe) take precedence over saved settings.
options = { ...options, queryTimeout: options.queryTimeout ?? connection.queryTimeout };
// An owned provider serves one user (docs/GOVERNANCE.md#per-user-providers): its pool opens nothing until that user
// asks, and never more than the user's share, whatever size the caller's options name. The rest of the caller's pool
// options stand.
if (execution.owner !== undefined) {
options = { ...options, pool: { ...options.pool, min: 0, max: execution.ownerPoolMax } };
}
switch (connection.type) {
// SQL Databases - dynamically imported to reduce memory
case "postgres": {
const { PostgresProvider } = await import("./providers/sql/postgres");
return new PostgresProvider(connection, options, execution);
}
case "mysql": {
const { MySQLProvider } = await import("./providers/sql/mysql");
return new MySQLProvider(connection, options, execution);
}
case "sqlite": {
const { SQLiteProvider } = await import("./providers/sql/sqlite");
return new SQLiteProvider(connection, options, execution);
}
case "duckdb": {
const { DuckDBProvider } = await import("./providers/sql/duckdb");
return new DuckDBProvider(connection, options, execution);
}
case "libsql": {
const { LibSQLProvider } = await import("./providers/sql/libsql");
return new LibSQLProvider(connection, options);
}
case "oracle": {
const { OracleProvider } = await import("./providers/sql/oracle");
return new OracleProvider(connection, options, execution);
}
case "db2": {
const { Db2Provider } = await import("./providers/sql/db2/index");
return new Db2Provider(connection, options);
}
case "mssql": {
const { MSSQLProvider } = await import("./providers/sql/mssql");
return new MSSQLProvider(connection, options, execution);
}
case "clickhouse": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { ClickHouseProvider } = await import("./providers/sql/clickhouse/index");
return new ClickHouseProvider(connection, options);
}
case "druid": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { DruidProvider } = await import("./providers/sql/druid/index");
return new DruidProvider(connection, options);
}
case "trino": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { TrinoProvider } = await import("./providers/sql/trino/index");
return new TrinoProvider(connection, options);
}
case "databend": {
// The explicit /index specifier keeps this dynamic import statically analysable. The constructor validates
// nothing and opens nothing; the connection's rules run in connect(), before any socket.
const { DatabendProvider } = await import("./providers/sql/databend/index");
return new DatabendProvider(connection, options);
}
case "bigquery": {
const { BigQueryProvider } = await import("./providers/sql/bigquery/index");
return new BigQueryProvider(connection, options);
}
case "snowflake": {
// The explicit /index specifier keeps this dynamic import statically analysable. Two arguments only: the
// transport deps are a test's seam, and the origin comes from the Account field alone.
const { SnowflakeProvider } = await import("./providers/sql/snowflake/index");
return new SnowflakeProvider(connection, options);
}
case "spark": {
// The explicit /index specifier keeps this dynamic import statically analysable. Two arguments only: the
// third, the wire transport, is a test's seam the factory never passes.
const { SparkProvider } = await import("./providers/sql/spark/index");
return new SparkProvider(connection, options);
}
case "cassandra": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { CassandraProvider } = await import("./providers/sql/cassandra/index");
return new CassandraProvider(connection, options);
}
// Search engines - two type-ids, ONE implementation module (issue #424 Phase 1).
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
case "elasticsearch": {
const { ElasticsearchProvider } = await import("./providers/sql/search/index");
return new ElasticsearchProvider(connection, options);
}
case "opensearch": {
const { OpenSearchProvider } = await import("./providers/sql/search/index");
return new OpenSearchProvider(connection, options);
}
// Document Databases - dynamically imported. The execution context reaches the
// MongoDB constructor like the SQL engines' and Kubernetes': its `readOnly` and
// `sendsStatements` together are what the connect-time verification keys on (issue #1614).
case "mongodb": {
const { MongoDBProvider } = await import("./providers/document/mongodb");
return new MongoDBProvider(connection, options, execution);
}
case "couchbase": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { CouchbaseProvider } = await import("./providers/document/couchbase/index");
return new CouchbaseProvider(connection, options);
}
case "firestore": {
// The explicit /index specifier keeps this dynamic import statically analysable. Read-only whatever the flag
// says, so no execution context rides along, as for Neo4j.
const { FirestoreProvider } = await import("./providers/document/firestore/index");
return new FirestoreProvider(connection, options);
}
// Key-Value Stores - dynamically imported
case "redis": {
const { RedisProvider } = await import("./providers/keyvalue/redis");
return new RedisProvider(connection, options);
}
case "etcd": {
// The explicit /index specifier keeps this dynamic import statically analysable. The
// execution context rides along, so an execution profile opens it read-only (#1089 E6).
const { EtcdProvider } = await import("./providers/keyvalue/etcd/index");
return new EtcdProvider(connection, options, execution);
}
case "oxia": {
// The explicit /index specifier keeps this dynamic import statically analysable. The execution context rides
// along, so a refusal names the read-only mode an execution profile set (O1).
const { OxiaProvider } = await import("./providers/keyvalue/oxia/index");
return new OxiaProvider(connection, options, execution);
}
// Time-series stores - dynamically imported
case "prometheus": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { PrometheusProvider } = await import("./providers/timeseries/prometheus/index");
return new PrometheusProvider(connection, options);
}
// Two type-ids served by one directory, one per query language (InfluxDB spec I2, I23). Both are read-only
// whatever the flag says, so no execution context rides along, as for Neo4j.
case "influxdb": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { InfluxDBProvider } = await import("./providers/timeseries/influxdb/index");
return new InfluxDBProvider(connection, options);
}
case "influxdb3": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { InfluxDB3Provider } = await import("./providers/timeseries/influxdb/index");
return new InfluxDB3Provider(connection, options);
}
// Message logs - dynamically imported
case "kafka": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { KafkaProvider } = await import("./providers/stream/kafka/index");
return new KafkaProvider(connection, options);
}
// Graph databases - dynamically imported
case "neo4j": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
// cannot trace into a chunk.
const { Neo4jProvider } = await import("./providers/graph/neo4j/index");
return new Neo4jProvider(connection, options);
}
// Vector databases - dynamically imported
case "milvus": {
// The explicit /index specifier keeps this dynamic import statically analysable. The execution context
// rides along, so an execution profile opens it read-only (vector-family spec E8).
const { MilvusProvider } = await import("./providers/vector/milvus/index");
return new MilvusProvider(connection, options, execution);
}
case "qdrant": {
// The explicit /index specifier keeps this dynamic import statically analysable. The execution context
// rides along, so an execution profile opens it read-only, as etcd's does.
const { QdrantProvider } = await import("./providers/vector/qdrant/index");
return new QdrantProvider(connection, options, execution);
}
// Object storage - dynamically imported
case "s3": {
// The explicit /index specifier keeps this dynamic import statically analysable. The execution context rides
// along, so an execution profile opens it read-only, as Qdrant's does.
const { S3Provider } = await import("./providers/objectstore/s3/index");
return new S3Provider(connection, options, execution);
}
// MQTT: a key-value provider, placed here beside the platforms because its Keys panel is the whole
// surface and it has no query console of its own.
case "mqtt": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { MqttProvider } = await import("./providers/keyvalue/mqtt/index");
return new MqttProvider(connection, options);
}
// RabbitMQ: the second member of the stream family, read-only whatever the flag says. No seam is passed: the
// provider runs on its production management transport, AMQP dial and clock.
case "rabbitmq": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { RabbitMQProvider } = await import("./providers/stream/rabbitmq/index");
return new RabbitMQProvider(connection, options);
}
// OPC UA: an industrial address space, browsed level by level. Read-only whatever the flag says,
// so no execution context rides along, as for Neo4j.
case "opcua": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { OpcuaProvider } = await import("./providers/industrial/opcua/index");
// THE LIBRARY IS IMPORTED BY THE FIRST CONNECT, NOT BY CONSTRUCTION, and that is measured
// rather than tidy: `node-opcua-hostname` calls `prepareFQDN()` at import time, un-awaited,
// and its `dns.lookupService` throws inside a callback where the library's own try/catch
// cannot see it. Under Node 24.14.0 here the hostname resolves to `::1` and nothing happens;
// under Bun 1.4.2 it resolved to a zone-indexed link-local address
// (`fe80::…%15`) and the error surfaced as an uncaught one. Constructing a provider must not
// depend on that, so the import happens when a session is actually opened.
return new OpcuaProvider(connection, options, async (read) => {
const { openLibrarySession } = await import("./providers/industrial/opcua/library");
return openLibrarySession(read);
});
}
// MTConnect: an agent in front of machine tools, read over HTTP. Read-only whatever the flag says,
// so no execution context rides along, as for OPC UA. No library: the client is this repository's
// own over the shared transport, so the import is the provider module and nothing else.
case "mtconnect": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { MTConnectProvider } = await import("./providers/industrial/mtconnect/index");
return new MTConnectProvider(connection, options);
}
// SNMP: an agent on a device, read over UDP by this repository's own client through the
// shared egress rule. Read-only not by a flag but by construction: the outgoing valve parses
// every datagram and refuses any PDU that is not a Get, GetNext or GetBulk, so a Set never
// reaches the socket. No library, so the import is the provider module itself.
case "snmp": {
const { SnmpProvider } = await import("./providers/network/snmp/index");
return new SnmpProvider(connection, options);
}
// Modbus TCP: a device or a gateway, read over a raw socket by this repository's own client
// through the shared egress rule. Read-only whatever the flag says, as for the two above, so
// no execution context rides along. No library: the client is ours, so the import is the
// provider module and nothing else.
case "modbus": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { ModbusProvider } = await import("./providers/industrial/modbus/index");
return new ModbusProvider(connection, options);
}
// Platforms - dynamically imported
case "kubernetes": {
// The explicit /index specifier keeps this dynamic import statically analysable. Read-only whatever the flag
// says; the execution context rides along, so `queryReadOnly` answers only under an agent profile.
const { KubernetesProvider } = await import("./providers/platform/kubernetes/index");
return new KubernetesProvider(connection, options, {}, execution);
}
// Network devices - dynamically imported
//
// Before `libredb` on purpose, and the reason is in the tests rather than in style: five
// regexes in `tests/isolated/factory.test.ts` anchor on `libredb$`, so a case appended after
// it breaks checks that have nothing to do with this engine.
case "netconf": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { NetconfProvider } = await import("./providers/network/netconf/index");
return new NetconfProvider(connection, options);
}
// Sparkplug B (#1720), the third member of the `industrial/` family. Its own bounded MQTT session
// rather than the shipped MQTT read, because that read returns one row per topic and a Sparkplug
// read depends on the order of successive messages on one topic.
case "sparkplug": {
// The explicit /index specifier keeps this dynamic import statically analysable.
const { SparkplugProvider } = await import("./providers/industrial/sparkplug/index");
return new SparkplugProvider(connection, options);
}
// Embedded databases - dynamically imported
case "libredb": {
const { LibreDBProvider } = await import("./providers/embedded/libredb");
return new LibreDBProvider(connection, options);
}
default:
throw new DatabaseConfigError(
// This list is NOT type-checked against the union - a new case above with no
// entry here is silent - so it is kept in the same order as the cases and
// tests/isolated/factory.test.ts pins individual names in it by regex.
`Unknown database type: ${connection.type}. Supported types: postgres, mysql, sqlite, duckdb, libsql, oracle, db2, mssql, clickhouse, druid, trino, databend, bigquery, snowflake, spark, cassandra, elasticsearch, opensearch, mongodb, couchbase, firestore, redis, etcd, oxia, prometheus, influxdb, influxdb3, kafka, neo4j, milvus, qdrant, s3, mqtt, opcua, mtconnect, modbus, snmp, rabbitmq, kubernetes, netconf, sparkplug, libredb`,
connection.type,
);
}
}
// ============================================================================
// SSH tunnel rewrite (#457, X23)
// ============================================================================
/**
* The connection a provider is built with when its traffic goes through an SSH tunnel: `host`
* and `port` point at the tunnel's LOCAL endpoint, which is where the driver must dial, and the
* address that endpoint FORWARDS TO, read back off the tunnel, travels with them under
* `TUNNEL_FAR_END`.
*
* The far end is carried because the rewrite alone made the whole tunnelled population unable to
* edit anything (X23). A provider seals every object edit plan with
* `connectionFingerprint(this.config)` and the edit routes recompute that digest from the record
* the request resolved, so a config that says `127.0.0.1:<ephemeral>` and a record that says
* `db.internal:5432` compared unequal on every attempt: the reader could open the object, could
* read it, and could never edit it.
*
* It is NOT on `ProviderOptions`, for the reason `ProviderExecutionContext`'s docblock already
* gives about a profile flag: options are caller-supplied and flow all the way into
* `getOrCreateProvider`, and a value the seal depends on must not be settable by whoever builds
* the options for a request. A symbol key is the same footing reached differently - see
* `TUNNEL_FAR_END` in `src/lib/types.ts` for what stops a stored connection carrying one.
*
* `base` is the connection as the caller already holds it - `acquireExecutionProfileProvider` has
* substituted the agent credential onto it by the time it gets here - and only `host` and `port`
* are replaced. The far end is not read off it at all, which is the point of the paragraph below.
*
* THE FAR END COMES OFF THE TUNNEL, never off the record, and that is why this helper takes no
* separate `farEnd` argument (D86). A pooled tunnel is not always the one this call opened, so
* the address the caller ASKED to forward to and the address the forward reaches are two
* different facts. MEASURED 2026-09-15 against the live bastion, no mocks: with the pool keyed
* on the connection id alone, a provider built on the id of a tunnel to `pg-p3fix:5432` with the
* record changed to `db-elsewhere.invalid:6543` dialled the existing forward, answered
* `libredb_dev` at `172.23.0.2`, and sealed `db-elsewhere.invalid:6543` - which is exactly what
* the edit routes recompute, so that plan verified against a machine the statement never reached.
* Two things closed it: `TunnelInfo` now carries `remoteHost`/`remotePort`, and the pool keys on
* them and on the bastion route, so a request for a forward nothing has opened opens its own.
*/
function tunnelledConnection(base: DatabaseConnection, tunnel: TunnelInfo): DatabaseConnection & WithTunnelFarEnd {
return {
...base,
host: tunnel.localHost,
port: tunnel.localPort,
[TUNNEL_FAR_END]: { host: tunnel.remoteHost, port: tunnel.remotePort },
};
}
// ============================================================================
// One-shot tunnel scope (#457)
// ============================================================================
/**
* Run `run` against a connection reachable through its SSH tunnel, then close the
* tunnel unconditionally.
*
* This is the transport for callers that build a provider with
* `createDatabaseProvider` directly - outside both provider caches - and connect it:
* `POST /api/db/test-connection` and `POST /api/db/schema-snapshot`. Before #457 they
* connected to the raw database host, so a tunnelled connection could never be tested
* and therefore never be saved at all (the dialog gates its only save button on a
* passing test), and the agent's grounding capture could not read a tunnelled schema.
*
* The tunnel is deliberately NOT pooled, unlike `getOrCreateProvider`'s. Those callers
* cache nothing, so nothing would ever evict a pooled tunnel: `removeProvider` and the
* idle sweep close the tunnel of a CACHED provider, and the connection dialog mints a
* fresh id for every unsaved build - so each test click would strand an SSH client and
* a listening local server for the life of the process. Ownership sits with this scope
* instead, which is why `run` is a callback rather than a returned endpoint: the close
* cannot be forgotten.
*
* The callback is NOT named `use`: `react-hooks/rules-of-hooks` reads a call to
* anything named `use()` as React 19's hook and rejects it outside a component, and
* inside a try block on top of that.
*
* A connection with no tunnel, or with no host and port to forward to (a
* connection-string connection, or SQLite), passes straight through untouched - the
* same rule the pooled paths apply.
*/
export async function withOneShotTunnel<T>(
connection: DatabaseConnection,
run: (effective: DatabaseConnection) => Promise<T>,
): Promise<T> {
assertProviderEnabled(connection);
assertReadOnlyHonoured(connection);
if (!connection.sshTunnel?.enabled || !connection.host || !connection.port) {
return await run(connection);
}
const tunnel = await createSSHTunnel(connection.id, connection.sshTunnel, connection.host, connection.port, {
shared: false,
});
try {
return await run(tunnelledConnection(connection, tunnel));
} finally {
// Swallowed on purpose: a failing teardown must not replace the caller's error,
// which is the one that says why the database connection did not work.
await tunnel.close().catch(() => {});
}
}
// ============================================================================
// Provider Cache (for connection reuse)
// ============================================================================
interface CachedProvider {
provider: DatabaseProvider;
lastUsed: number;
/**
* The connection this entry serves. It is NOT the cache key - see
* {@link providerCacheKey} for why the key may not be a string the caller typed. Every
* "which entries serve connection X" question matches on this field.
*/
connectionId: string;
/**
* The user this entry was opened for (docs/GOVERNANCE.md#per-user-providers), the `owner` its key frames, or
* undefined for an entry every caller of the connection shares. Recorded for the reason `connectionId` is: the key is
* a digest and cannot be read back, so "which entries are this user's" matches on this field. An entry with an owner
* is torn down when it goes (`teardownProvider`), never only disconnected.
*/
owner?: string;
/**
* The file this entry holds open, when the provider declares
* `ProviderCapabilities.singleWriterFile` - see `findOpenSingleWriterProvider`.
* `null` on every other provider, which is all of them but one.
*/
singleWriterFile?: string | null;
/**
* Whether this entry's DuckDB handle was opened with file access allowed (non-admin DuckDB file access): the
* derived posture `getOrCreateProvider` opened it under. Only the single-writer borrow for
* DuckDB reads it, so a handle is lent only to a caller of the same posture and nobody is
* handed a handle wider than its own. It is the derived boolean, so it is `true`/`false`
* rather than absent, and it is ignored for every engine but DuckDB (the borrow gates on it
* only there).
*/
allowExternalFileAccess?: boolean;
}
const providerCache = new Map<string, CachedProvider>();
// ============================================================================
// Single-writer file reuse (#498)
// ============================================================================
/**
* The identity of a database FILE: the engine type plus the resolved absolute path.
*
* The path is what identifies the open handle, not the connection id, because the
* second opener is usually a different connection record pointing at the same file -
* which is exactly how D3 reproduced (the built-in "Sample (LibreDB)" holds the file
* while the dialog tests the edited copy under a fresh id). The type is in the key so
* two engines can never collide over one path.
*
* `null` when the connection carries no file path at all, which is every
* client-server connection, every connection-string one, and every ANONYMOUS
* IN-MEMORY one.
*
* That last case is not a nicety. `path.resolve(":memory:")` is a path in the working
* directory, so two unrelated in-memory connections came out with ONE identity and the
* second borrowed the first's open handle - reading a database nobody pointed it at.
* It fires on the engines that declare `singleWriterFile`, which is where the borrow
* lives: DuckDB accepts `:memory:` (LibreDB does not). An anonymous in-memory database
* is per-handle by definition - there is no file to share, no lock to work around, and
* therefore nothing a borrow could buy.
*
* The comparison is EXACT, matching `DuckDBProvider.getDatabasePath()` character for
* character: that method treats `:memory:` and nothing else as in-memory, and resolves
* every other spelling - `:MEMORY:`, ` :memory:`, DuckDB's own `:memory:named` - as a
* relative file path. Two connections spelling one of those the same way really do
* point at one file, and must keep matching. The narrowing here is only ever as wide as
* the provider's own reading of the value.
*/
const ANONYMOUS_IN_MEMORY_DATABASE = ":memory:";
function fileIdentity(connection: DatabaseConnection): string | null {
if (!connection.database) return null;
if (connection.database === ANONYMOUS_IN_MEMORY_DATABASE) return null;
return `${connection.type}::${path.resolve(connection.database)}`;
}
/**
* The connected provider that already holds this connection's file, if there is one.
*
* Only a provider whose engine declares `ProviderCapabilities.singleWriterFile` ever
* registers a file identity here (`getOrCreateProvider` below), so a match means the
* engine admits ONE handle per file and the caller's alternative is not a second,
* lesser handle - it is no handle at all. Borrowers must NOT disconnect what they get
* back: the returned provider is owned by the writable cache and serves the user's
* live connection.
*
* Deliberately reads only the writable cache. The reverse direction - handing an
* editor request a provider opened under an execution profile - stays forbidden, so
* profiled entries are not offered here.
*
* POSTURE-AWARE FOR DUCKDB (non-admin DuckDB file access). A DuckDB entry records the file-access posture it was
* opened under, and a borrow matches only an entry opened under the caller's own posture, so a
* caller is never lent a handle wider than its own. Both sides are normalised to `=== true`, so
* an absent caller posture (no argument) reads as deny, matching the default
* `getOrCreateProvider` records and keeping the fail-closed polarity. The gate is skipped for
* every other engine (libredb), where no such posture exists, so their borrow is unchanged.
* Under `editorExecutionContext` every role that can resolve one seed record gets the same
* posture, so on a seed the gate never splits one record between two handles. When it finds
* nothing to lend while the file is open, `isSingleWriterFileOpen` says so, and Test Connection
* opens a read-only handle rather than a second read-write one.
*
* The cost of that is real and is paid by the editor. If an agent run reaches a
* single-writer connection nobody has browsed yet, `acquireExecutionProfileProvider`
* opens the file and caches the handle under the PROFILED key; `getOrCreateProvider`
* then fails on the lock, so browsing that connection in the sidebar is refused until
* the profiled entry is evicted (30 minutes idle). Lifting it would mean serving an
* editor request a provider opened under an execution profile, which is the isolation
* invariant `acquireExecutionProfileProvider` exists to keep - so the lockout is the
* chosen side of that trade, not an oversight.
*/
export function findOpenSingleWriterProvider(
connection: DatabaseConnection,
allowExternalFileAccess?: boolean,
): DatabaseProvider | null {
const identity = fileIdentity(connection);
if (identity === null) return null;
// Normalised so an absent caller posture reads as deny, matching the default entries
// `getOrCreateProvider` records; the gate below only consults it for DuckDB.
const wantAccess = allowExternalFileAccess === true;
for (const entry of providerCache.values()) {
if (entry.singleWriterFile !== identity || !entry.provider.isConnected()) continue;
// A handle of an engine that opens under a file-access posture is borrowable only by a caller of
// its own posture (the non-admin DuckDB file-access change). `READS_FILE_ACCESS_POSTURE` answers
// which engines, so this is not a `connection.type` branch (CLAUDE.md); today DuckDB and SQLite do, and
// of the two only DuckDB is a single-writer engine.
if (READS_FILE_ACCESS_POSTURE[connection.type] && (entry.allowExternalFileAccess === true) !== wantAccess) {
continue;
}
return entry.provider;
}
return null;
}
/**
* Whether a connected handle in the writable cache holds this connection's file, whatever
* file-access posture it was opened under (non-admin DuckDB file access).
*
* `findOpenSingleWriterProvider` answers only with a handle of the caller's own posture, so when it
* answers null and this answers true, the file is held by a DuckDB handle of the OTHER posture. A
* caller that would otherwise open its own read-write handle there must not: DuckDB serves one file
* through one read-write handle per process, and a second one keeps its own copy of the catalog and
* checkpoints it over the file when it closes, which loses the other handle's committed rows on Linux
* and macOS and is refused outright on Windows. Test Connection opens a read-only handle instead,
* which leaves the writer's file and write-ahead log alone (`src/lib/api/test-connection.ts`).
*/
export function isSingleWriterFileOpen(connection: DatabaseConnection): boolean {
const identity = fileIdentity(connection);
if (identity === null) return false;
return Array.from(providerCache.values()).some(
(entry) => entry.singleWriterFile === identity && entry.provider.isConnected(),
);
}
// ============================================================================
// Execution-profile provider cache (#328)
// ----------------------------------------------------------------------------
// Physically separate from providerCache on purpose: an agent acquisition must
// be able to prove it never read from nor wrote to the shared writable cache.
// Keyed by (connection id, execution profile), plus the requester's posture for SQLite and DuckDB.
// ============================================================================
type ProfiledCachedProvider = CachedProvider;
const profiledProviderCache = new Map<string, ProfiledCachedProvider>();
/**
* The profiled cache's key, which is the shared cache's key with the profile framed onto it
* (GHSA-3wh2-8x78-jfw4). It used to be `${profile}::${connection.id}`, so it carried the same
* forgeable id and was reachable by the same forgery - reproduced on this path too, in
* `tests/isolated/factory.test.ts`. {@link providerCacheKey} states the whole argument.
*
* The profile stays in the key because the two caches' isolation is per profile: an
* `agent-read-only` acquisition may never be served what `agent-operations` opened.
*
* The requester's file-access posture is in it too, for an engine that reads one, exactly as in
* the writable key: SQLite refuses a denied requester at connect, so a handle a trusted requester
* opened must never answer a denied requester's key. Absent reads as denied. So is the requester's
* owner (docs/GOVERNANCE.md#per-user-providers), as in the writable key: one user's acquisition is never served
* another's.
*
* Exported for one caller that must join concurrent first acquisitions on exactly this key
* without deriving a second one: src/lib/mcp/context.ts.
*/
export async function profiledCacheKey(
connection: DatabaseConnection,
profile: ExecutionProfile,
requester: EditorExecutionContext = {},
): Promise<string> {
const key = await providerCacheKey(connection, requester.allowExternalFileAccess === true, requester.owner);
return withProfile(profile, key);
}
/**
* `key`, a key of the writable cache, with `profile` framed onto it: the key the profiled cache holds the entry of that
* profile under. {@link profiledCacheKey} keys an acquisition with it, and the key scope of `removeProvider` names the
* profiled entries of one record with it, so the two never frame a profile differently.
*/
function withProfile(profile: string, key: string): string {
return `${profile.length}:${profile}${key}`;
}
/** True when any provider — shared or profiled — still serves this connection. */
function connectionStillServed(connectionId: string): boolean {
if (Array.from(providerCache.values()).some((entry) => entry.connectionId === connectionId)) return true;
return Array.from(profiledProviderCache.values()).some((entry) => entry.connectionId === connectionId);
}
// ============================================================================
// Teardown of an owned provider (docs/GOVERNANCE.md#per-user-providers)
// ============================================================================
/**
* How long a teardown waits for the rollback of an owned provider's open transaction
* (docs/GOVERNANCE.md#per-user-providers).
*/
const TEARDOWN_ROLLBACK_MS = 5_000;
/**
* How long a teardown waits for an owned provider's disconnect before it abandons the pool
* (docs/GOVERNANCE.md#per-user-providers).
*/
const TEARDOWN_DISCONNECT_MS = 10_000;
/** What `within` answers for work that did not settle before its deadline. */
const TIMED_OUT = Symbol("timed out");
/**
* Waits for `work` at most `ms`: answers once it resolves, or TIMED_OUT once `ms` pass first, and throws what it
* rejects with before then. A rejection after the deadline is already handled by the race, and the timer never holds
* the process open.
*/
async function within(work: Promise<void>, ms: number): Promise<void | typeof TIMED_OUT> {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
timer = setTimeout(() => resolve(TIMED_OUT), ms);
timer.unref?.();
});
try {
return await Promise.race([work, deadline]);
} finally {
clearTimeout(timer);
}
}
/** The interactive transaction session that `POST /api/db/transaction` drives, on a provider that implements one. */
interface TransactionSession {
isInTransaction(): boolean;
rollbackTransaction(): Promise<void>;
}
/** The provider's transaction session while it holds a transaction open, or null. */
function openTransaction(provider: DatabaseProvider): TransactionSession | null {
const session = provider as DatabaseProvider & Partial<TransactionSession>;
if (typeof session.isInTransaction !== "function" || typeof session.rollbackTransaction !== "function") return null;
return session.isInTransaction() ? (session as TransactionSession) : null;
}
/**
* Tear down an owned provider (docs/GOVERNANCE.md#per-user-providers): the provider of one user, closed because that
* user's access changed or the provider went idle. Its entry is deleted from `cache` first, so no request is handed the
* provider while it closes; an open transaction is rolled back, waited for at most TEARDOWN_ROLLBACK_MS; and the
* provider is disconnected, waited for at most TEARDOWN_DISCONNECT_MS. The rollback comes first because the disconnect
* would otherwise wait on the very session the transaction holds: `pg`'s `pool.end()` waits for every checked-out
* client.
*
* A rollback or a disconnect that fails is logged, and a disconnect that does not finish in time is logged and its pool
* abandoned, because a client stuck in a statement, or in a rollback that never answered, never comes back. So the
* teardown ends within 15 s whatever the user's database does, and it never rejects.
*/
async function teardownProvider(cache: Map<string, CachedProvider>, key: string, entry: CachedProvider): Promise<void> {
cache.delete(key);
const { provider, connectionId: id } = entry;
try {
// Inside the try, as `keepsRunInFlight` guards `hasRunsInFlight`: a provider that throws here is not left open.
const session = openTransaction(provider);
if (session !== null) {
const rollback = await within(session.rollbackTransaction(), TEARDOWN_ROLLBACK_MS);
if (rollback === TIMED_OUT) {
logger.warn(
`[DB] The open transaction of an owned provider of ${sanitize(id)} did not roll back within ${TEARDOWN_ROLLBACK_MS} ms, so the provider is closed with it open.`,
{ connectionId: id },
);
} else {
const line = `[DB] Rolled back the open transaction of an owned provider of ${sanitize(id)} before closing it.`;
logger.info(line, { connectionId: id });
}
}
} catch (error) {
logger.warn(
`[DB] The open transaction of an owned provider of ${sanitize(id)} failed to roll back, so the provider is closed with it open.`,
{ connectionId: id, error: String(error) },
);
}
try {
if ((await within(provider.disconnect(), TEARDOWN_DISCONNECT_MS)) === TIMED_OUT) {
logger.warn(
`[DB] Abandoned the pool of owned provider ${sanitize(id)}: its disconnect did not finish within ${TEARDOWN_DISCONNECT_MS} ms.`,
{ connectionId: id },
);
}
} catch (error) {
logger.warn(`[DB] Error disconnecting owned provider ${sanitize(id)}`, { connectionId: id, error: String(error) });
}
}
/** Idle timeout: evict providers unused for 30 minutes */
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
/** Sweep interval: check for idle providers every 5 minutes */
const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
let sweepTimer: ReturnType<typeof setInterval> | null = null;
/**
* Whether an idle entry's provider reports a run in flight, so the sweep keeps it for the next sweep.
* A throw is the provider's defect: logged at error, and the entry is evicted as one without the method, because
* rethrowing would abandon the rest of the sweep and reading it as busy would let such a provider pin its entry.
*/
function keepsRunInFlight(
provider: DatabaseProvider,
id: string,
noun: "idle provider" | "idle profiled provider",