@otwdesign/db-core 1.0.2 → 1.2.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.
@@ -3,6 +3,7 @@ import { connect } from 'node:net'
3
3
  import type { FieldDef, Pool, PoolClient, PoolConfig } from 'pg'
4
4
  import { ConnectionError, DbAdminError, QueryError } from '../errors.ts'
5
5
  import {
6
+ DATABASES_SQL,
6
7
  detail,
7
8
  dialect,
8
9
  INFO_SQL,
@@ -21,6 +22,7 @@ import type {
21
22
  ApplyResult,
22
23
  Capabilities,
23
24
  Change,
25
+ DatabaseList,
24
26
  Driver,
25
27
  QueryOptions,
26
28
  QueryResult,
@@ -50,47 +52,117 @@ interface Modules {
50
52
  Cursor: typeof CursorClass
51
53
  }
52
54
 
53
- let modules: Promise<Modules> | undefined
55
+ /**
56
+ * Imports pg and pg-cursor, as `() => Promise.all([import('pg'), import('pg-cursor')])` does.
57
+ * Each may be its import's namespace or the package's own export.
58
+ */
59
+ export type PostgresModules = () => Promise<readonly [pg: unknown, pgCursor: unknown]>
54
60
 
55
- /** pg and pg-cursor, imported on the first PostgreSQL connection, so other hosts never load them. */
56
- function loadModules(): Promise<Modules> {
57
- // Imported where the host runs, not bundled: a site installs only the drivers it uses, and a
58
- // bundler (Next.js) mustn't fail on one it doesn't have.
59
- modules ??= Promise.all([
61
+ /** How a PostgreSQL driver gets the packages it runs on. */
62
+ export interface PostgresOptions {
63
+ /**
64
+ * Where pg and pg-cursor come from. By default they're imported where the app runs, from the
65
+ * app's own dependencies. A package that brings them as its own dependencies and is bundled into
66
+ * the app, such as the OTW CMS plugin, passes its own imports: the app's bundler resolves those
67
+ * from that package, which pnpm requires, since it keeps a package's dependencies beside it.
68
+ */
69
+ modules?: PostgresModules
70
+ }
71
+
72
+ // Imported where the app runs, not bundled: an app installs only the drivers it uses, and a
73
+ // bundler (Next.js) mustn't fail on one it doesn't have.
74
+ const fromApp: PostgresModules = () =>
75
+ Promise.all([
60
76
  import(/* webpackIgnore: true */ /* turbopackIgnore: true */ 'pg'),
61
77
  import(/* webpackIgnore: true */ /* turbopackIgnore: true */ 'pg-cursor'),
62
- ]).then(
63
- ([pg, cursor]) => ({ Pool: pg.Pool, Cursor: cursor.default }),
64
- (error: unknown) => {
65
- modules = undefined
66
- throw new DbAdminError(
67
- `PostgreSQL connections need the pg and pg-cursor packages: npm install pg pg-cursor (${(error as Error).message})`,
68
- 500,
69
- 'driver-missing',
70
- )
71
- },
72
- )
73
- return modules
78
+ ])
79
+
80
+ let appModules: Promise<Modules> | undefined
81
+
82
+ /** pg and pg-cursor, imported on the first PostgreSQL connection, so other hosts never load them. */
83
+ function loadModules(given: PostgresModules | undefined): Promise<Modules> {
84
+ if (given) return load(given, 'Could not load pg and pg-cursor')
85
+ appModules ??= load(
86
+ fromApp,
87
+ 'PostgreSQL connections need the pg and pg-cursor packages, installed in the app: npm install pg pg-cursor',
88
+ ).catch((error: unknown) => {
89
+ appModules = undefined
90
+ throw error
91
+ })
92
+ return appModules
93
+ }
94
+
95
+ async function load(modules: PostgresModules, failure: string): Promise<Modules> {
96
+ let pg: unknown, cursor: unknown
97
+ try {
98
+ ;[pg, cursor] = await modules()
99
+ } catch (error) {
100
+ throw new DbAdminError(`${failure}: ${(error as Error).message}`, 500, 'driver-missing')
101
+ }
102
+ // A CommonJS package's import has its export as the default, and pg's ESM entry names Pool too.
103
+ const exports = pg as { Pool?: typeof Pool; default?: { Pool?: typeof Pool } }
104
+ const PoolClass = exports.Pool ?? exports.default?.Pool
105
+ const Cursor = (cursor as { default?: unknown } | undefined)?.default ?? cursor
106
+ if (typeof PoolClass !== 'function' || typeof Cursor !== 'function') {
107
+ throw new DbAdminError(`${failure}: they aren't pg's and pg-cursor's`, 500, 'driver-missing')
108
+ }
109
+ return { Pool: PoolClass, Cursor: Cursor as typeof CursorClass }
110
+ }
111
+
112
+ /** A placeholder host for a URL with none, as pg's own parser uses: `user@/db?host=/socket`. */
113
+ const NO_HOST = '___otw_db_no_host___'
114
+
115
+ /**
116
+ * `url` at another of its server's databases: its path swapped for `database`, its login, host and
117
+ * settings kept. pg reads the path back with decodeURI, which leaves `?` and `#` escaped, so a
118
+ * name with either can't be carried.
119
+ */
120
+ export function postgresUrlAt(url: string, database: string): string {
121
+ if (/[?#]/.test(database)) {
122
+ throw new DbAdminError(
123
+ `The database "${database}" can't be opened: a connection URL can't name a database with ? or #.`,
124
+ 400,
125
+ 'bad-database',
126
+ )
127
+ }
128
+ const hostless = url.includes('@/')
129
+ let parsed: URL
130
+ try {
131
+ parsed = new URL(hostless ? url.replace('@/', `@${NO_HOST}/`) : url)
132
+ } catch {
133
+ // Not the URL itself, which may hold a password.
134
+ throw new DbAdminError(
135
+ "The connection's URL can't be moved to another database.",
136
+ 500,
137
+ 'bad-url',
138
+ )
139
+ }
140
+ parsed.pathname = `/${encodeURI(database)}`
141
+ return hostless ? parsed.href.replace(`@${NO_HOST}/`, '@/') : parsed.href
74
142
  }
75
143
 
76
144
  /**
77
145
  * A PostgreSQL driver for `url`. It connects on first use, with a pool of up to three
78
146
  * connections, each closed after 10 s unused; an admin tool needs few.
79
147
  */
80
- export function postgresDriver(url: string): Driver {
81
- return new PostgresDriver(url)
148
+ export function postgresDriver(url: string, options: PostgresOptions = {}): Driver {
149
+ return new PostgresDriver(url, options.modules)
82
150
  }
83
151
 
84
152
  class PostgresDriver implements Driver {
85
153
  readonly kind = 'postgres'
86
154
  readonly capabilities: Capabilities = { schemas: true, transactionalDdl: true, cancel: true }
87
155
  readonly sql = { identifier: quote, literal }
156
+ readonly #url: string
88
157
  readonly #config: PoolConfig
158
+ readonly #modules: PostgresModules | undefined
89
159
  #pool: Promise<{ pool: Pool; modules: Modules }> | undefined
90
160
  /** Type names by OID, filled as results bring new ones. */
91
161
  readonly #typeNames = new Map<number, string>()
92
162
 
93
- constructor(url: string) {
163
+ constructor(url: string, modules: PostgresModules | undefined) {
164
+ this.#url = url
165
+ this.#modules = modules
94
166
  this.#config = {
95
167
  connectionString: url,
96
168
  max: 3,
@@ -101,12 +173,19 @@ class PostgresDriver implements Driver {
101
173
  }
102
174
 
103
175
  #open(): Promise<{ pool: Pool; modules: Modules }> {
104
- this.#pool ??= loadModules().then((modules) => {
105
- const pool = new modules.Pool(this.#config)
106
- // An idle connection the server drops would otherwise take the whole process with it.
107
- pool.on('error', (error) => console.error('otw-db: a PostgreSQL connection failed', error))
108
- return { pool, modules }
109
- })
176
+ this.#pool ??= loadModules(this.#modules).then(
177
+ (modules) => {
178
+ const pool = new modules.Pool(this.#config)
179
+ // An idle connection the server drops would otherwise take the whole process with it.
180
+ pool.on('error', (error) => console.error('otw-db: a PostgreSQL connection failed', error))
181
+ return { pool, modules }
182
+ },
183
+ (error: unknown) => {
184
+ // Nothing opened: the next call tries again.
185
+ this.#pool = undefined
186
+ throw error
187
+ },
188
+ )
110
189
  return this.#pool
111
190
  }
112
191
 
@@ -179,6 +258,18 @@ class PostgresDriver implements Driver {
179
258
  return this.#run(sql, undefined, options)
180
259
  }
181
260
 
261
+ async databases(): Promise<DatabaseList> {
262
+ const rows = await this.#catalog<{ name: string; current: string }>(DATABASES_SQL)
263
+ return {
264
+ current: rows.find((row) => row.current === 't')?.name ?? '',
265
+ databases: rows.map((row) => row.name),
266
+ }
267
+ }
268
+
269
+ withDatabase(name: string): Driver {
270
+ return new PostgresDriver(postgresUrlAt(this.#url, name), this.#modules)
271
+ }
272
+
182
273
  async apply(
183
274
  { schema, table }: { schema: string; table: string },
184
275
  changes: readonly Change[],
@@ -332,7 +423,9 @@ class PostgresDriver implements Driver {
332
423
  async close(): Promise<void> {
333
424
  const opened = this.#pool
334
425
  this.#pool = undefined
335
- if (opened) await (await opened).pool.end()
426
+ // One that failed to open has nothing to close.
427
+ const pool = await opened?.catch(() => undefined)
428
+ await pool?.pool.end()
336
429
  }
337
430
  }
338
431
 
package/src/handler.ts CHANGED
@@ -82,6 +82,10 @@ const QUERY_ID = /^[A-Za-z0-9_-]{1,64}$/
82
82
  const MAX_FILTERS = 20
83
83
  const MAX_CHANGES = 1_000
84
84
  const MAX_LIST = 1_000
85
+ /** A connection's other databases kept open at once; the one used longest ago closes first. */
86
+ const MAX_DATABASES = 8
87
+ /** PostgreSQL's NAMEDATALEN, less its terminator: the longest database name, in bytes. */
88
+ const MAX_DATABASE_NAME = 63
85
89
  const OPERATORS: ReadonlySet<string> = new Set<FilterOperator>([
86
90
  '=',
87
91
  '<>',
@@ -127,6 +131,8 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
127
131
  const limits = { ...DEFAULT_LIMITS, ...options.limits }
128
132
  const audit = options.audit === false ? undefined : (options.audit ?? logAudit)
129
133
  const drivers = new Map<string, Driver>()
134
+ /** Each connection's other databases' drivers, the one used last at the end. */
135
+ const others = new Map<string, Map<string, Driver>>()
130
136
  const running = new Map<string, { viewer: string; controller: AbortController }>()
131
137
  const open = options.driver ?? openDriver
132
138
  const loadUi = options.ui === false ? undefined : (options.ui ?? defaultUi)
@@ -141,14 +147,55 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
141
147
  return found
142
148
  }
143
149
 
144
- /** The connection by name, with this viewer's access to it; unknown and hidden look the same. */
145
- function reach(viewer: Viewer, name: unknown): { connection: ConnectionConfig; access: Access } {
150
+ /**
151
+ * The driver for one of the connection's other databases. The 8 used last stay open; opening a
152
+ * ninth closes the one used longest ago, so a viewer can't keep pools open without end.
153
+ */
154
+ function driverAt(connection: ConnectionConfig, database: string): Driver {
155
+ let open = others.get(connection.name)
156
+ if (!open) others.set(connection.name, (open = new Map()))
157
+ let found = open.get(database)
158
+ if (found) open.delete(database)
159
+ else found = driver(connection).withDatabase!(database)
160
+ open.set(database, found)
161
+ if (open.size > MAX_DATABASES) {
162
+ const [oldest, evicted] = open.entries().next().value!
163
+ open.delete(oldest)
164
+ evicted.close().catch((error: unknown) => {
165
+ console.error(`otw-db: could not close database "${oldest}" of "${connection.name}"`, error)
166
+ })
167
+ }
168
+ return found
169
+ }
170
+
171
+ /**
172
+ * The connection by name, with this viewer's access to it and the driver for `database`: one of
173
+ * the server's other databases, or the connection's own when none is named. Unknown and hidden
174
+ * connections look the same.
175
+ */
176
+ function reach(
177
+ viewer: Viewer,
178
+ name: unknown,
179
+ database?: unknown,
180
+ ): { connection: ConnectionConfig; access: Access; db: Driver; database?: string } {
146
181
  const connection = typeof name === 'string' ? connections.get(name) : undefined
147
182
  const access = connection ? accessOf(viewer, connection) : null
148
183
  if (!connection || !access) {
149
184
  throw new DbAdminError(`There's no connection "${String(name)}".`, 404, 'unknown-connection')
150
185
  }
151
- return { connection, access }
186
+ if (database === undefined || database === null || database === '') {
187
+ return { connection, access, db: driver(connection) }
188
+ }
189
+ const named = databaseName(database)
190
+ if (!opensOthers(connection)) throw oneDatabase(connection)
191
+ return { connection, access, db: driverAt(connection, named), database: named }
192
+ }
193
+
194
+ /** Whether the connection may open its server's other databases, and its driver can. */
195
+ function opensOthers(connection: ConnectionConfig): boolean {
196
+ if (connection.databases === 'one') return false
197
+ const opened = driver(connection)
198
+ return typeof opened.databases === 'function' && typeof opened.withDatabase === 'function'
152
199
  }
153
200
 
154
201
  async function record(event: Omit<AuditEvent, 'at'>): Promise<void> {
@@ -176,7 +223,10 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
176
223
  // A kind without a driver yet is listed, but can't be opened.
177
224
  let capabilities: Driver['capabilities'] | null = null
178
225
  try {
179
- capabilities = driver(connection).capabilities
226
+ capabilities = {
227
+ ...driver(connection).capabilities,
228
+ databases: opensOthers(connection),
229
+ }
180
230
  } catch (error) {
181
231
  if (!(error instanceof DbAdminError)) throw error
182
232
  }
@@ -188,12 +238,25 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
188
238
  return { connections: visible }
189
239
  },
190
240
  },
241
+ {
242
+ method: 'GET',
243
+ path: '/api/databases',
244
+ async run(request, viewer) {
245
+ const { connection, db } = reach(
246
+ viewer,
247
+ new URL(request.url).searchParams.get('connection'),
248
+ )
249
+ if (!opensOthers(connection)) throw oneDatabase(connection)
250
+ return await db.databases!()
251
+ },
252
+ },
191
253
  {
192
254
  method: 'GET',
193
255
  path: '/api/tables',
194
256
  async run(request, viewer) {
195
- const { connection } = reach(viewer, new URL(request.url).searchParams.get('connection'))
196
- return { tables: await driver(connection).tables() }
257
+ const params = new URL(request.url).searchParams
258
+ const { db } = reach(viewer, params.get('connection'), params.get('database'))
259
+ return { tables: await db.tables() }
197
260
  },
198
261
  },
199
262
  {
@@ -201,8 +264,8 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
201
264
  path: '/api/table',
202
265
  async run(request, viewer) {
203
266
  const params = new URL(request.url).searchParams
204
- const { connection } = reach(viewer, params.get('connection'))
205
- const table = await driver(connection).table(
267
+ const { db } = reach(viewer, params.get('connection'), params.get('database'))
268
+ const table = await db.table(
206
269
  text(params.get('schema'), 'schema'),
207
270
  text(params.get('name'), 'name'),
208
271
  )
@@ -214,8 +277,9 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
214
277
  method: 'GET',
215
278
  path: '/api/info',
216
279
  async run(request, viewer) {
217
- const { connection } = reach(viewer, new URL(request.url).searchParams.get('connection'))
218
- return { info: await driver(connection).info() }
280
+ const params = new URL(request.url).searchParams
281
+ const { db } = reach(viewer, params.get('connection'), params.get('database'))
282
+ return { info: await db.info() }
219
283
  },
220
284
  },
221
285
  {
@@ -223,7 +287,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
223
287
  path: '/api/count',
224
288
  async run(request, viewer) {
225
289
  const body = await json(request)
226
- const { connection } = reach(viewer, body.connection)
290
+ const { connection, db, database } = reach(viewer, body.connection, body.database)
227
291
  const target = {
228
292
  schema: text(body.schema, 'schema'),
229
293
  table: text(body.table, 'table'),
@@ -232,13 +296,14 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
232
296
  const started = performance.now()
233
297
  const statement = `count ${target.schema}.${target.table}`
234
298
  try {
235
- const rows = await driver(connection).count(target, {
299
+ const rows = await db.count(target, {
236
300
  timeoutMs: limits.timeoutMs,
237
301
  signal: request.signal,
238
302
  })
239
303
  await record({
240
304
  viewer: viewer.id,
241
305
  connection: connection.name,
306
+ ...(database ? { database } : {}),
242
307
  action: 'count',
243
308
  statement,
244
309
  durationMs: Math.round(performance.now() - started),
@@ -249,6 +314,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
249
314
  await record({
250
315
  viewer: viewer.id,
251
316
  connection: connection.name,
317
+ ...(database ? { database } : {}),
252
318
  action: 'count',
253
319
  statement,
254
320
  durationMs: Math.round(performance.now() - started),
@@ -263,18 +329,19 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
263
329
  path: '/api/rows',
264
330
  async run(request, viewer) {
265
331
  const body = await json(request)
266
- const { connection } = reach(viewer, body.connection)
332
+ const { connection, db, database } = reach(viewer, body.connection, body.database)
267
333
  const page = rowsRequest(body)
268
334
  const started = performance.now()
269
335
  const statement = `${page.schema}.${page.table}`
270
336
  try {
271
- const result = await driver(connection).rows(page, {
337
+ const result = await db.rows(page, {
272
338
  timeoutMs: limits.timeoutMs,
273
339
  signal: request.signal,
274
340
  })
275
341
  await record({
276
342
  viewer: viewer.id,
277
343
  connection: connection.name,
344
+ ...(database ? { database } : {}),
278
345
  action: 'rows',
279
346
  statement,
280
347
  durationMs: result.durationMs,
@@ -285,6 +352,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
285
352
  await record({
286
353
  viewer: viewer.id,
287
354
  connection: connection.name,
355
+ ...(database ? { database } : {}),
288
356
  action: 'rows',
289
357
  statement,
290
358
  durationMs: Math.round(performance.now() - started),
@@ -299,7 +367,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
299
367
  path: '/api/query',
300
368
  async run(request, viewer) {
301
369
  const body = await json(request)
302
- const { connection, access } = reach(viewer, body.connection)
370
+ const { connection, access, db, database } = reach(viewer, body.connection, body.database)
303
371
  const sql = text(body.sql, 'sql')
304
372
  const maxRows =
305
373
  body.maxRows === undefined
@@ -318,7 +386,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
318
386
  }
319
387
  const started = performance.now()
320
388
  try {
321
- const result = await driver(connection).query(sql, {
389
+ const result = await db.query(sql, {
322
390
  access,
323
391
  maxRows,
324
392
  timeoutMs: limits.timeoutMs,
@@ -327,6 +395,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
327
395
  await record({
328
396
  viewer: viewer.id,
329
397
  connection: connection.name,
398
+ ...(database ? { database } : {}),
330
399
  action: 'query',
331
400
  statement: sql,
332
401
  durationMs: result.durationMs,
@@ -337,6 +406,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
337
406
  await record({
338
407
  viewer: viewer.id,
339
408
  connection: connection.name,
409
+ ...(database ? { database } : {}),
340
410
  action: 'query',
341
411
  statement: sql,
342
412
  durationMs: Math.round(performance.now() - started),
@@ -354,7 +424,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
354
424
  path: '/api/apply',
355
425
  async run(request, viewer) {
356
426
  const body = await json(request)
357
- const { connection, access } = reach(viewer, body.connection)
427
+ const { connection, access, db, database } = reach(viewer, body.connection, body.database)
358
428
  const target = { schema: text(body.schema, 'schema'), table: text(body.table, 'table') }
359
429
  const changes = changesOf(body.changes)
360
430
  const dryRun = body.dryRun === true
@@ -363,7 +433,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
363
433
  }
364
434
  const started = performance.now()
365
435
  try {
366
- const result = await driver(connection).apply(target, changes, {
436
+ const result = await db.apply(target, changes, {
367
437
  timeoutMs: limits.timeoutMs,
368
438
  dryRun,
369
439
  })
@@ -371,6 +441,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
371
441
  await record({
372
442
  viewer: viewer.id,
373
443
  connection: connection.name,
444
+ ...(database ? { database } : {}),
374
445
  action: 'apply',
375
446
  statement: result.statements.join(';\n'),
376
447
  durationMs: Math.round(performance.now() - started),
@@ -383,6 +454,7 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
383
454
  await record({
384
455
  viewer: viewer.id,
385
456
  connection: connection.name,
457
+ ...(database ? { database } : {}),
386
458
  action: 'apply',
387
459
  statement: `${changes.length} changes to ${target.schema}.${target.table}`,
388
460
  durationMs: Math.round(performance.now() - started),
@@ -398,12 +470,11 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
398
470
  path: '/api/export',
399
471
  async run(request, viewer) {
400
472
  const body = await json(request)
401
- const { connection } = reach(viewer, body.connection)
473
+ const { connection, db, database } = reach(viewer, body.connection, body.database)
402
474
  const format = formatOf(body.format)
403
475
  const page = rowsRequest({ ...body, limit: undefined, offset: undefined })
404
- const opened = driver(connection)
405
476
  const started = performance.now()
406
- const batches = opened.export(page, { timeoutMs: limits.timeoutMs })
477
+ const batches = db.export(page, { timeoutMs: limits.timeoutMs })
407
478
  // Read the first batch before answering, so a bad table or filter is a proper error.
408
479
  const first = await batches.next()
409
480
  let replayed = false
@@ -419,11 +490,12 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
419
490
  format,
420
491
  replay,
421
492
  page,
422
- opened.sql,
493
+ db.sql,
423
494
  (rows, error) =>
424
495
  void record({
425
496
  viewer: viewer.id,
426
497
  connection: connection.name,
498
+ ...(database ? { database } : {}),
427
499
  action: 'export',
428
500
  statement: `${page.schema}.${page.table} as ${format}`,
429
501
  durationMs: Math.round(performance.now() - started),
@@ -541,8 +613,9 @@ export function createDbAdmin(options: DbAdminOptions): DbAdmin {
541
613
  return reply(404, { error: { code: 'not-found', message: 'Not found.' } })
542
614
  },
543
615
  async close() {
544
- const open = [...drivers.values()]
616
+ const open = [...drivers.values(), ...[...others.values()].flatMap((o) => [...o.values()])]
545
617
  drivers.clear()
618
+ others.clear()
546
619
  await Promise.all(open.map((d) => d.close()))
547
620
  },
548
621
  }
@@ -576,6 +649,26 @@ async function defaultUi(): Promise<UiAssets> {
576
649
  return assets
577
650
  }
578
651
 
652
+ function oneDatabase(connection: ConnectionConfig): DbAdminError {
653
+ return new DbAdminError(
654
+ `Connection "${connection.name}" opens its own database only.`,
655
+ 400,
656
+ 'one-database',
657
+ )
658
+ }
659
+
660
+ /** A database's name as PostgreSQL takes it: up to 63 bytes, with no NUL. */
661
+ function databaseName(value: unknown): string {
662
+ if (
663
+ typeof value !== 'string' ||
664
+ value.includes('\0') ||
665
+ new TextEncoder().encode(value).length > MAX_DATABASE_NAME
666
+ ) {
667
+ throw bad('"database" is a database’s name.')
668
+ }
669
+ return value
670
+ }
671
+
579
672
  function accessOf(viewer: Viewer, connection: ConnectionConfig): Access | null {
580
673
  const granted = viewer.access ? viewer.access(connection) : connection.access
581
674
  if (!granted) return null
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export { connectionsFromEnv, driverKind } from './connections.ts'
2
- export { openDriver } from './drivers/index.ts'
2
+ export { openDriver, type DriverOptions } from './drivers/index.ts'
3
3
  export { backtick, mysqlDriver, mysqlLiteral } from './drivers/mysql.ts'
4
4
  export { sqliteDriver, sqliteLiteral, sqlitePath } from './drivers/sqlite.ts'
5
5
  export { pgliteDriver, type PGliteLike, type PGliteQueryable } from './drivers/pglite.ts'
6
- export { postgresDriver } from './drivers/postgres.ts'
6
+ export { postgresDriver, type PostgresModules, type PostgresOptions } from './drivers/postgres.ts'
7
7
  export { literal as literalPostgres, quote as quotePostgres } from './drivers/postgres-catalog.ts'
8
8
  export { ConnectionError, DbAdminError, QueryError } from './errors.ts'
9
9
  export { EXPORT_TYPES, exportStream, type ExportBatch } from './export.ts'
package/src/types.ts CHANGED
@@ -20,6 +20,12 @@ export interface ConnectionConfig {
20
20
  tag?: ConnectionTag
21
21
  /** Shown wherever people run SQL on it, such as what a heavy statement does to the host. */
22
22
  warning?: string
23
+ /**
24
+ * Which of its server's databases it opens, where a connection opens one database at a time
25
+ * (PostgreSQL): `all`, the default, every one its login may connect to, the URL's first; `one`,
26
+ * the URL's only, for a login with more privileges than the tool should show.
27
+ */
28
+ databases?: 'all' | 'one'
23
29
  }
24
30
 
25
31
  /** A connection's environment, as a color: production red, staging orange, and so on. */
@@ -36,6 +42,11 @@ export interface Capabilities {
36
42
  * runs on the host's own thread.
37
43
  */
38
44
  cancel: boolean
45
+ /**
46
+ * The server's other databases open from the connection, by name. The API sets it: a driver
47
+ * offers them with `databases()` and `withDatabase()`, and a connection may be pinned to its own.
48
+ */
49
+ databases?: boolean
39
50
  }
40
51
 
41
52
  /** What a schema-tree entry is: SQLite and MySQL have tables and views, PostgreSQL the rest too. */
@@ -195,6 +206,14 @@ export interface ServerInfo {
195
206
  user: string
196
207
  }
197
208
 
209
+ /** The databases a connection's login may open on its server, for the database picker. */
210
+ export interface DatabaseList {
211
+ /** The one the driver opened. */
212
+ current: string
213
+ /** By name, the current one among them. */
214
+ databases: string[]
215
+ }
216
+
198
217
  /** How many rows one statement may send back, and how long it may run. */
199
218
  export interface Limits {
200
219
  /** Rows sent back at most; the rest are cut, and the result says so. */
@@ -242,6 +261,16 @@ export interface Driver {
242
261
  readonly sql: SqlDialect
243
262
  /** One statement. */
244
263
  query(sql: string, options: QueryOptions): Promise<QueryResult>
264
+ /**
265
+ * The server's databases its login may open, where a connection opens one at a time
266
+ * (PostgreSQL). Absent where it has one (SQLite) or shows the rest as schemas (MySQL).
267
+ */
268
+ databases?(): Promise<DatabaseList>
269
+ /**
270
+ * A driver for another of the server's databases, with the same login and settings. It connects
271
+ * on first use; whoever asked for it closes it.
272
+ */
273
+ withDatabase?(name: string): Driver
245
274
  close(): Promise<void>
246
275
  }
247
276
 
@@ -261,6 +290,8 @@ export interface AuditEvent {
261
290
  at: string
262
291
  viewer: string
263
292
  connection: string
293
+ /** The database it ran on, when it wasn't the connection's own. */
294
+ database?: string
264
295
  action: 'query' | 'rows' | 'count' | 'apply' | 'export' | 'cancel'
265
296
  /** The SQL run, or for `rows`, the table read. */
266
297
  statement: string