@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.
- package/dist/connections.d.ts +4 -3
- package/dist/connections.js +14 -3
- package/dist/connections.js.map +1 -1
- package/dist/drivers/index.d.ts +7 -1
- package/dist/drivers/index.js +2 -2
- package/dist/drivers/index.js.map +1 -1
- package/dist/drivers/postgres-catalog.d.ts +5 -0
- package/dist/drivers/postgres-catalog.js +10 -0
- package/dist/drivers/postgres-catalog.js.map +1 -1
- package/dist/drivers/postgres.d.ts +22 -1
- package/dist/drivers/postgres.js +81 -18
- package/dist/drivers/postgres.js.map +1 -1
- package/dist/handler.js +105 -23
- package/dist/handler.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +30 -0
- package/package.json +2 -2
- package/src/connections.ts +13 -3
- package/src/drivers/index.ts +9 -3
- package/src/drivers/postgres-catalog.ts +11 -0
- package/src/drivers/postgres.ts +121 -28
- package/src/handler.ts +116 -23
- package/src/index.ts +2 -2
- package/src/types.ts +31 -0
package/src/drivers/postgres.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
])
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
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(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
145
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
196
|
-
|
|
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 {
|
|
205
|
-
const table = await
|
|
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
|
|
218
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|