orez 0.2.9 → 0.2.11
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/admin/server.d.ts +7 -0
- package/dist/admin/server.d.ts.map +1 -1
- package/dist/admin/server.js +260 -0
- package/dist/admin/server.js.map +1 -1
- package/dist/admin/ui.d.ts.map +1 -1
- package/dist/admin/ui.js +1320 -725
- package/dist/admin/ui.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/pg-proxy-browser.d.ts.map +1 -1
- package/dist/pg-proxy-browser.js +91 -44
- package/dist/pg-proxy-browser.js.map +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +1 -0
- package/dist/vite-plugin.js.map +1 -1
- package/dist/worker/shims/ws-browser.d.ts.map +1 -1
- package/dist/worker/shims/ws-browser.js +23 -1
- package/dist/worker/shims/ws-browser.js.map +1 -1
- package/package.json +2 -2
- package/src/admin/admin-data.test.ts +296 -0
- package/src/admin/server.ts +277 -0
- package/src/admin/ui.ts +1320 -727
- package/src/cli.ts +2 -0
- package/src/pg-proxy-browser.ts +98 -52
- package/src/vite-plugin.ts +1 -0
- package/src/worker/shims/ws-browser.ts +25 -1
package/src/admin/server.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
1
2
|
import {
|
|
2
3
|
createServer,
|
|
3
4
|
type Server,
|
|
4
5
|
type IncomingMessage,
|
|
5
6
|
type ServerResponse,
|
|
6
7
|
} from 'node:http'
|
|
8
|
+
import { resolve } from 'node:path'
|
|
7
9
|
|
|
8
10
|
import { log } from '../log.js'
|
|
9
11
|
import { getAdminHtml } from './ui.js'
|
|
@@ -11,6 +13,7 @@ import { getAdminHtml } from './ui.js'
|
|
|
11
13
|
import type { ZeroLiteConfig } from '../config.js'
|
|
12
14
|
import type { HttpLogStore } from './http-proxy.js'
|
|
13
15
|
import type { LogStore } from './log-store.js'
|
|
16
|
+
import type { PGlite } from '@electric-sql/pglite'
|
|
14
17
|
|
|
15
18
|
export interface AdminActions {
|
|
16
19
|
restartZero?: () => Promise<void>
|
|
@@ -19,6 +22,12 @@ export interface AdminActions {
|
|
|
19
22
|
resetZeroFull?: () => Promise<void>
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
export interface AdminDbInstances {
|
|
26
|
+
postgres: PGlite
|
|
27
|
+
cvr: PGlite
|
|
28
|
+
cdb: PGlite
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export interface AdminServerOpts {
|
|
23
32
|
port: number
|
|
24
33
|
logStore: LogStore
|
|
@@ -27,6 +36,7 @@ export interface AdminServerOpts {
|
|
|
27
36
|
actions?: AdminActions
|
|
28
37
|
startTime: number
|
|
29
38
|
httpLog?: HttpLogStore
|
|
39
|
+
db?: AdminDbInstances
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
const CORS_HEADERS: Record<string, string> = {
|
|
@@ -48,6 +58,7 @@ function json(res: ServerResponse, data: unknown, status = 200) {
|
|
|
48
58
|
const UI_PATHS = new Set([
|
|
49
59
|
'/',
|
|
50
60
|
'/all',
|
|
61
|
+
'/data',
|
|
51
62
|
'/zero',
|
|
52
63
|
'/pglite',
|
|
53
64
|
'/proxy',
|
|
@@ -175,6 +186,231 @@ export function startAdminServer(opts: AdminServerOpts): Promise<Server> {
|
|
|
175
186
|
return
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
// db explorer endpoints
|
|
190
|
+
if (opts.db && req.method === 'GET' && url.pathname === '/api/db/tables') {
|
|
191
|
+
const dbName = url.searchParams.get('db') || 'postgres'
|
|
192
|
+
const instance = getDbInstance(opts.db, dbName)
|
|
193
|
+
if (!instance) {
|
|
194
|
+
json(res, { error: 'unknown db: ' + dbName }, 400)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const result = await instance.query(
|
|
199
|
+
`SELECT table_schema, table_name, pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name)) as size_bytes
|
|
200
|
+
FROM information_schema.tables
|
|
201
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
202
|
+
ORDER BY table_schema, table_name`
|
|
203
|
+
)
|
|
204
|
+
json(res, { tables: result.rows })
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
json(res, { error: err?.message ?? 'query failed' }, 500)
|
|
207
|
+
}
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (opts.db && req.method === 'GET' && url.pathname === '/api/db/table-data') {
|
|
212
|
+
const dbName = url.searchParams.get('db') || 'postgres'
|
|
213
|
+
const table = url.searchParams.get('table')
|
|
214
|
+
if (!table) {
|
|
215
|
+
json(res, { error: 'missing table param' }, 400)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
const instance = getDbInstance(opts.db, dbName)
|
|
219
|
+
if (!instance) {
|
|
220
|
+
json(res, { error: 'unknown db: ' + dbName }, 400)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const search = url.searchParams.get('search') || ''
|
|
224
|
+
const offset = Number(url.searchParams.get('offset') || '0')
|
|
225
|
+
const limit = Number(url.searchParams.get('limit') || '100')
|
|
226
|
+
try {
|
|
227
|
+
// get columns first
|
|
228
|
+
const colResult = await instance.query(
|
|
229
|
+
`SELECT column_name, data_type FROM information_schema.columns
|
|
230
|
+
WHERE table_schema || '.' || table_name = $1 OR table_name = $1
|
|
231
|
+
ORDER BY ordinal_position`,
|
|
232
|
+
[table]
|
|
233
|
+
)
|
|
234
|
+
const columns = colResult.rows.map((r: any) => ({
|
|
235
|
+
name: r.column_name,
|
|
236
|
+
type: r.data_type,
|
|
237
|
+
}))
|
|
238
|
+
// build query with optional search
|
|
239
|
+
let sql = `SELECT * FROM ${quoteIdentPg(table)}`
|
|
240
|
+
const params: any[] = []
|
|
241
|
+
if (search) {
|
|
242
|
+
// search across all text-castable columns
|
|
243
|
+
const conds = columns.map(
|
|
244
|
+
(_: any, i: number) =>
|
|
245
|
+
`${quoteIdentPg(columns[i].name)}::text ILIKE $${params.length + 1}`
|
|
246
|
+
)
|
|
247
|
+
if (conds.length > 0) {
|
|
248
|
+
params.push('%' + search + '%')
|
|
249
|
+
sql += ' WHERE ' + conds.join(' OR ')
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// get total count
|
|
253
|
+
const countResult = await instance.query(
|
|
254
|
+
`SELECT count(*)::int as total FROM (${sql}) _c`,
|
|
255
|
+
params
|
|
256
|
+
)
|
|
257
|
+
const total = (countResult.rows[0] as any)?.total ?? 0
|
|
258
|
+
sql += ` LIMIT ${limit} OFFSET ${offset}`
|
|
259
|
+
const result = await instance.query(sql, params)
|
|
260
|
+
json(res, {
|
|
261
|
+
columns,
|
|
262
|
+
rows: result.rows,
|
|
263
|
+
total,
|
|
264
|
+
offset,
|
|
265
|
+
limit,
|
|
266
|
+
})
|
|
267
|
+
} catch (err: any) {
|
|
268
|
+
json(res, { error: err?.message ?? 'query failed' }, 500)
|
|
269
|
+
}
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (opts.db && req.method === 'POST' && url.pathname === '/api/db/query') {
|
|
274
|
+
const body = await readBody(req)
|
|
275
|
+
let parsed: { db?: string; sql?: string }
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(body)
|
|
278
|
+
} catch {
|
|
279
|
+
json(res, { error: 'invalid json body' }, 400)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
const dbName = parsed.db || 'postgres'
|
|
283
|
+
const sql = parsed.sql
|
|
284
|
+
if (!sql) {
|
|
285
|
+
json(res, { error: 'missing sql' }, 400)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const instance = getDbInstance(opts.db, dbName)
|
|
289
|
+
if (!instance) {
|
|
290
|
+
json(res, { error: 'unknown db: ' + dbName }, 400)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const start = performance.now()
|
|
295
|
+
const result = await instance.query(sql)
|
|
296
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100
|
|
297
|
+
json(res, {
|
|
298
|
+
fields: (result.fields || []).map((f: any) => f.name),
|
|
299
|
+
rows: result.rows,
|
|
300
|
+
rowCount: result.rows.length,
|
|
301
|
+
durationMs,
|
|
302
|
+
})
|
|
303
|
+
} catch (err: any) {
|
|
304
|
+
json(res, { error: err?.message ?? 'query failed' }, 400)
|
|
305
|
+
}
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// sqlite replica endpoints
|
|
310
|
+
if (req.method === 'GET' && url.pathname === '/api/sqlite/tables') {
|
|
311
|
+
const sqliteDb = openSqliteReplica(opts.config.dataDir)
|
|
312
|
+
if (!sqliteDb) {
|
|
313
|
+
json(res, { error: 'sqlite replica not found' }, 404)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const tables = sqliteDb
|
|
318
|
+
.prepare(
|
|
319
|
+
`SELECT name, (SELECT count(*) FROM pragma_table_info(m.name)) as col_count
|
|
320
|
+
FROM sqlite_master m WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
321
|
+
ORDER BY name`
|
|
322
|
+
)
|
|
323
|
+
.all()
|
|
324
|
+
json(res, { tables })
|
|
325
|
+
} catch (err: any) {
|
|
326
|
+
json(res, { error: err?.message ?? 'query failed' }, 500)
|
|
327
|
+
} finally {
|
|
328
|
+
sqliteDb.close()
|
|
329
|
+
}
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (req.method === 'GET' && url.pathname === '/api/sqlite/table-data') {
|
|
334
|
+
const table = url.searchParams.get('table')
|
|
335
|
+
if (!table) {
|
|
336
|
+
json(res, { error: 'missing table param' }, 400)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const sqliteDb = openSqliteReplica(opts.config.dataDir)
|
|
340
|
+
if (!sqliteDb) {
|
|
341
|
+
json(res, { error: 'sqlite replica not found' }, 404)
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
const search = url.searchParams.get('search') || ''
|
|
345
|
+
const offset = Number(url.searchParams.get('offset') || '0')
|
|
346
|
+
const limit = Number(url.searchParams.get('limit') || '100')
|
|
347
|
+
try {
|
|
348
|
+
const columns = sqliteDb
|
|
349
|
+
.prepare(`SELECT name, type FROM pragma_table_info(?)`)
|
|
350
|
+
.all(table)
|
|
351
|
+
const quotedTable = '"' + table.replace(/"/g, '""') + '"'
|
|
352
|
+
let sql = `SELECT * FROM ${quotedTable}`
|
|
353
|
+
const params: any[] = []
|
|
354
|
+
if (search) {
|
|
355
|
+
const conds = columns.map(
|
|
356
|
+
(c: any) => `"${c.name.replace(/"/g, '""')}" LIKE ?`
|
|
357
|
+
)
|
|
358
|
+
if (conds.length > 0) {
|
|
359
|
+
params.push(...conds.map(() => '%' + search + '%'))
|
|
360
|
+
sql += ' WHERE ' + conds.join(' OR ')
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const countRow = sqliteDb
|
|
364
|
+
.prepare(`SELECT count(*) as total FROM (${sql})`)
|
|
365
|
+
.get(...params)
|
|
366
|
+
const total = (countRow as any)?.total ?? 0
|
|
367
|
+
sql += ` LIMIT ? OFFSET ?`
|
|
368
|
+
params.push(limit, offset)
|
|
369
|
+
const stmt = sqliteDb.prepare(sql)
|
|
370
|
+
const rows = stmt.all(...params)
|
|
371
|
+
json(res, { columns, rows, total, offset, limit })
|
|
372
|
+
} catch (err: any) {
|
|
373
|
+
json(res, { error: err?.message ?? 'query failed' }, 500)
|
|
374
|
+
} finally {
|
|
375
|
+
sqliteDb.close()
|
|
376
|
+
}
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (req.method === 'POST' && url.pathname === '/api/sqlite/query') {
|
|
381
|
+
const body = await readBody(req)
|
|
382
|
+
let parsed: { sql?: string }
|
|
383
|
+
try {
|
|
384
|
+
parsed = JSON.parse(body)
|
|
385
|
+
} catch {
|
|
386
|
+
json(res, { error: 'invalid json body' }, 400)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
const sql = parsed.sql
|
|
390
|
+
if (!sql) {
|
|
391
|
+
json(res, { error: 'missing sql' }, 400)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
const sqliteDb = openSqliteReplica(opts.config.dataDir)
|
|
395
|
+
if (!sqliteDb) {
|
|
396
|
+
json(res, { error: 'sqlite replica not found' }, 404)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const start = performance.now()
|
|
401
|
+
const stmt = sqliteDb.prepare(sql)
|
|
402
|
+
const fields = stmt.columns().map((c: any) => c.name)
|
|
403
|
+
const rows = stmt.all()
|
|
404
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100
|
|
405
|
+
json(res, { fields, rows, rowCount: rows.length, durationMs })
|
|
406
|
+
} catch (err: any) {
|
|
407
|
+
json(res, { error: err?.message ?? 'query failed' }, 400)
|
|
408
|
+
} finally {
|
|
409
|
+
sqliteDb.close()
|
|
410
|
+
}
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
|
|
178
414
|
res.writeHead(404, CORS_HEADERS)
|
|
179
415
|
res.end('not found')
|
|
180
416
|
} catch (err: any) {
|
|
@@ -189,3 +425,44 @@ export function startAdminServer(opts: AdminServerOpts): Promise<Server> {
|
|
|
189
425
|
server.on('error', reject)
|
|
190
426
|
})
|
|
191
427
|
}
|
|
428
|
+
|
|
429
|
+
function getDbInstance(db: AdminDbInstances, name: string): PGlite | null {
|
|
430
|
+
if (name === 'postgres' || name === 'main') return db.postgres
|
|
431
|
+
if (name === 'cvr') return db.cvr
|
|
432
|
+
if (name === 'cdb') return db.cdb
|
|
433
|
+
return null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
437
|
+
return new Promise((resolve, reject) => {
|
|
438
|
+
const chunks: Buffer[] = []
|
|
439
|
+
req.on('data', (c) => chunks.push(c))
|
|
440
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString()))
|
|
441
|
+
req.on('error', reject)
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function quoteIdentPg(name: string): string {
|
|
446
|
+
if (name.includes('.')) {
|
|
447
|
+
return name
|
|
448
|
+
.split('.')
|
|
449
|
+
.map((p) => '"' + p.replace(/"/g, '""') + '"')
|
|
450
|
+
.join('.')
|
|
451
|
+
}
|
|
452
|
+
if (/^[a-z_][a-z0-9_]*$/.test(name)) return name
|
|
453
|
+
return '"' + name.replace(/"/g, '""') + '"'
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function openSqliteReplica(dataDir: string): any | null {
|
|
457
|
+
const replicaPath = resolve(dataDir, 'zero-replica.db')
|
|
458
|
+
if (!existsSync(replicaPath)) return null
|
|
459
|
+
try {
|
|
460
|
+
// dynamic import would be async — use require for sync bedrock-sqlite
|
|
461
|
+
const BedrockSqlite = require('bedrock-sqlite')
|
|
462
|
+
const Ctor =
|
|
463
|
+
BedrockSqlite.Database || BedrockSqlite.default?.Database || BedrockSqlite
|
|
464
|
+
return new Ctor(replicaPath, { readonly: true })
|
|
465
|
+
} catch {
|
|
466
|
+
return null
|
|
467
|
+
}
|
|
468
|
+
}
|