orez 0.2.10 → 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.
@@ -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
+ }