@bungres/kit 1.0.0 → 1.1.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/cli.js CHANGED
@@ -1664,6 +1664,37 @@ function generateDropColumn(tableName, schema, columnName) {
1664
1664
  const tbl = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`;
1665
1665
  return `ALTER TABLE ${tbl} DROP COLUMN IF EXISTS "${columnName}";`;
1666
1666
  }
1667
+ function generateCreateEnum(name, values) {
1668
+ const vals = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
1669
+ return `CREATE TYPE "${name}" AS ENUM (${vals});`;
1670
+ }
1671
+ function generateDropEnum(name, ifExists = true) {
1672
+ return `DROP TYPE${ifExists ? " IF EXISTS" : ""} "${name}";`;
1673
+ }
1674
+ function inlineParams(chunk) {
1675
+ let { sql: sql2, params } = chunk;
1676
+ return sql2.replace(/\$(\d+)/g, (_, n) => {
1677
+ const p = params[parseInt(n) - 1];
1678
+ if (typeof p === "string")
1679
+ return `'${p.replace(/'/g, "''")}'`;
1680
+ if (typeof p === "number")
1681
+ return String(p);
1682
+ if (typeof p === "boolean")
1683
+ return p ? "TRUE" : "FALSE";
1684
+ if (p === null)
1685
+ return "NULL";
1686
+ return `'${String(p).replace(/'/g, "''")}'`;
1687
+ });
1688
+ }
1689
+ function generateCreateView(view) {
1690
+ const kind = view.materialized ? "MATERIALIZED VIEW" : "VIEW";
1691
+ const inlineSql = inlineParams(view.query.toSQL());
1692
+ return `CREATE ${kind} "${view.name}" AS ${inlineSql};`;
1693
+ }
1694
+ function generateDropView(view, ifExists = true) {
1695
+ const kind = view.materialized ? "MATERIALIZED VIEW" : "VIEW";
1696
+ return `DROP ${kind}${ifExists ? " IF EXISTS" : ""} "${view.name}";`;
1697
+ }
1667
1698
 
1668
1699
  // src/schema-loader.ts
1669
1700
  async function loadSchemas(patterns, cwd = process.cwd()) {
@@ -1677,11 +1708,27 @@ async function loadSchemas(patterns, cwd = process.cwd()) {
1677
1708
  for (const [exportName, value] of Object.entries(mod)) {
1678
1709
  if (isTable(value)) {
1679
1710
  entries.push({
1711
+ type: "table",
1680
1712
  exportName,
1681
1713
  config: value[TableConfigSymbol],
1682
1714
  table: value,
1683
1715
  filePath: absPath
1684
1716
  });
1717
+ } else if (isEnum(value)) {
1718
+ entries.push({
1719
+ type: "enum",
1720
+ exportName,
1721
+ enumName: value.enumName,
1722
+ enumValues: value.enumValues,
1723
+ filePath: absPath
1724
+ });
1725
+ } else if (isView(value)) {
1726
+ entries.push({
1727
+ type: "view",
1728
+ exportName,
1729
+ config: value,
1730
+ filePath: absPath
1731
+ });
1685
1732
  }
1686
1733
  }
1687
1734
  }
@@ -1691,6 +1738,12 @@ async function loadSchemas(patterns, cwd = process.cwd()) {
1691
1738
  function isTable(value) {
1692
1739
  return typeof value === "object" && value !== null && TableConfigSymbol in value;
1693
1740
  }
1741
+ function isEnum(value) {
1742
+ return typeof value === "function" && "enumName" in value && "enumValues" in value && Array.isArray(value.enumValues);
1743
+ }
1744
+ function isView(value) {
1745
+ return typeof value === "object" && value !== null && "name" in value && "query" in value && typeof value.query === "object" && typeof value.query.toSQL === "function";
1746
+ }
1694
1747
 
1695
1748
  // ../../node_modules/.bun/@clack+core@1.4.3/node_modules/@clack/core/dist/index.mjs
1696
1749
  import { styleText } from "util";
@@ -3058,9 +3111,12 @@ async function runDrop(config2, opts = {}) {
3058
3111
  const s = spinner();
3059
3112
  s.start("Loading schemas...");
3060
3113
  const schemas2 = await loadSchemas(config2.schema);
3061
- if (schemas2.length === 0) {
3114
+ const tableSchemas = schemas2.filter((s2) => s2.type === "table");
3115
+ const enumSchemas = schemas2.filter((s2) => s2.type === "enum");
3116
+ const viewSchemas = schemas2.filter((s2) => s2.type === "view");
3117
+ if (tableSchemas.length === 0 && enumSchemas.length === 0 && viewSchemas.length === 0) {
3062
3118
  s.stop("No schemas found.");
3063
- log.warn(import_picocolors.default.yellow("No table definitions found in schema files."));
3119
+ log.warn(import_picocolors.default.yellow("No definitions found in schema files."));
3064
3120
  outro("Failed.");
3065
3121
  return;
3066
3122
  }
@@ -3082,25 +3138,31 @@ async function runDrop(config2, opts = {}) {
3082
3138
  WHERE table_schema = $1 AND table_name = $2
3083
3139
  ) AS exists`, [config2.migrationsSchema, "__bungres_push"]);
3084
3140
  const pushTableExists = pushTableCheck[0]?.exists ?? false;
3085
- const tablesToDrop = schemas2.filter((sch) => existingTableNames.has(sch.config.name));
3086
- if (tablesToDrop.length === 0 && !migrationTableExists && !pushTableExists) {
3141
+ const tablesToDrop = tableSchemas.filter((sch) => existingTableNames.has(sch.config.name));
3142
+ if (tablesToDrop.length === 0 && enumSchemas.length === 0 && viewSchemas.length === 0 && !migrationTableExists && !pushTableExists) {
3087
3143
  ms.stop("Nothing to do.");
3088
- log.success(import_picocolors.default.green("No tables to drop (they either don't exist or were already dropped)."));
3144
+ log.success(import_picocolors.default.green("No items to drop (they either don't exist or were already dropped)."));
3089
3145
  outro("Done.");
3090
3146
  return;
3091
3147
  }
3092
3148
  ms.stop("Database check complete.");
3093
- const tableNamesToPrint = tablesToDrop.map((sch) => (sch.config.schema ? sch.config.schema + "." : "") + sch.config.name);
3149
+ const namesToPrint = tablesToDrop.map((sch) => (sch.config.schema ? sch.config.schema + "." : "") + sch.config.name + " (table)");
3150
+ for (const v of viewSchemas) {
3151
+ namesToPrint.push(`${v.config.name} (view)`);
3152
+ }
3153
+ for (const e of enumSchemas) {
3154
+ namesToPrint.push(`${e.enumName} (enum)`);
3155
+ }
3094
3156
  if (migrationTableExists) {
3095
- tableNamesToPrint.push(`${config2.migrationsSchema}.${config2.migrationsTable}`);
3157
+ namesToPrint.push(`${config2.migrationsSchema}.${config2.migrationsTable} (table)`);
3096
3158
  }
3097
3159
  if (pushTableExists) {
3098
- tableNamesToPrint.push(`${config2.migrationsSchema}.__bungres_push`);
3160
+ namesToPrint.push(`${config2.migrationsSchema}.__bungres_push (table)`);
3099
3161
  }
3100
3162
  if (!opts.force) {
3101
3163
  log.warn(import_picocolors.default.bgRed(import_picocolors.default.white(" \u26A0\uFE0F WARNING ")));
3102
- log.message(import_picocolors.default.bold(import_picocolors.default.red("This will drop the following tables and ALL their data:")));
3103
- for (const name of tableNamesToPrint) {
3164
+ log.message(import_picocolors.default.bold(import_picocolors.default.red("This will drop the following items and ALL their data:")));
3165
+ for (const name of namesToPrint) {
3104
3166
  log.info(import_picocolors.default.yellow(` - ${name}`));
3105
3167
  }
3106
3168
  const confirm2 = await confirm({
@@ -3114,11 +3176,22 @@ async function runDrop(config2, opts = {}) {
3114
3176
  }
3115
3177
  const exSpinner = spinner();
3116
3178
  exSpinner.start("Dropping tables...");
3179
+ for (const entry of viewSchemas) {
3180
+ const isMat = entry.config.materialized ? "MATERIALIZED VIEW" : "VIEW";
3181
+ const ddl = `DROP ${isMat} IF EXISTS "${entry.config.name}" CASCADE;`;
3182
+ await sql2.unsafe(ddl);
3183
+ log.step(import_picocolors.default.gray(`Dropped ${entry.config.name}`));
3184
+ }
3117
3185
  for (const entry of tablesToDrop) {
3118
3186
  const ddl = `DROP TABLE IF EXISTS "${entry.config.name}" CASCADE;`;
3119
3187
  await sql2.unsafe(ddl);
3120
3188
  log.step(import_picocolors.default.gray(`Dropped ${entry.config.name}`));
3121
3189
  }
3190
+ for (const entry of enumSchemas) {
3191
+ const ddl = `DROP TYPE IF EXISTS "${entry.enumName}" CASCADE;`;
3192
+ await sql2.unsafe(ddl);
3193
+ log.step(import_picocolors.default.gray(`Dropped ${entry.enumName}`));
3194
+ }
3122
3195
  if (migrationTableExists) {
3123
3196
  const qualifiedMigTable = `"${config2.migrationsSchema}"."${config2.migrationsTable}"`;
3124
3197
  await sql2.unsafe(`DROP TABLE IF EXISTS ${qualifiedMigTable} CASCADE`);
@@ -3128,7 +3201,7 @@ async function runDrop(config2, opts = {}) {
3128
3201
  await sql2.unsafe(`DROP TABLE IF EXISTS "${config2.migrationsSchema}"."__bungres_push" CASCADE`);
3129
3202
  log.step(import_picocolors.default.gray(`Dropped ${config2.migrationsSchema}.__bungres_push`));
3130
3203
  }
3131
- exSpinner.stop("Tables dropped.");
3204
+ exSpinner.stop("Items dropped.");
3132
3205
  outro(import_picocolors.default.cyan("\u2728 Drop complete."));
3133
3206
  } catch (err) {
3134
3207
  log.error(import_picocolors.default.red(`Drop failed: ${err.message}`));
@@ -3235,12 +3308,56 @@ function diffSchemas(prev, next) {
3235
3308
  const statements = [];
3236
3309
  const summary = [];
3237
3310
  const warnings = [];
3238
- const prevTables = new Set(Object.keys(prev));
3239
- const nextTables = new Set(Object.keys(next));
3311
+ const prevEnums = prev.enums || {};
3312
+ const nextEnums = next.enums || {};
3313
+ const prevEnumNames = new Set(Object.keys(prevEnums));
3314
+ const nextEnumNames = new Set(Object.keys(nextEnums));
3315
+ for (const enumName of nextEnumNames) {
3316
+ if (!prevEnumNames.has(enumName)) {
3317
+ const e = nextEnums[enumName];
3318
+ statements.push(generateCreateEnum(e.enumName, e.enumValues));
3319
+ summary.push(`CREATE TYPE ${e.enumName}`);
3320
+ }
3321
+ }
3322
+ for (const enumName of prevEnumNames) {
3323
+ if (!nextEnumNames.has(enumName)) {
3324
+ const e = prevEnums[enumName];
3325
+ statements.push(generateDropEnum(e.enumName, true));
3326
+ summary.push(`DROP TYPE ${e.enumName}`);
3327
+ }
3328
+ }
3329
+ for (const enumName of nextEnumNames) {
3330
+ if (prevEnumNames.has(enumName)) {
3331
+ const p2 = prevEnums[enumName];
3332
+ const n2 = nextEnums[enumName];
3333
+ if (JSON.stringify(p2.enumValues) !== JSON.stringify(n2.enumValues)) {
3334
+ warnings.push(`Enum '${n2.enumName}' values have changed. Bungres Kit currently requires manual migration for ALTER TYPE ... ADD VALUE.`);
3335
+ }
3336
+ }
3337
+ }
3338
+ const prevViews = prev.views || {};
3339
+ const nextViews = next.views || {};
3340
+ const prevViewNames = new Set(Object.keys(prevViews));
3341
+ const nextViewNames = new Set(Object.keys(nextViews));
3342
+ for (const viewName of prevViewNames) {
3343
+ if (!nextViewNames.has(viewName)) {
3344
+ statements.push(generateDropView(prevViews[viewName]));
3345
+ summary.push(`DROP VIEW ${viewName}`);
3346
+ } else {
3347
+ const pSql = generateCreateView(prevViews[viewName]);
3348
+ const nSql = generateCreateView(nextViews[viewName]);
3349
+ if (pSql !== nSql) {
3350
+ statements.push(generateDropView(prevViews[viewName]));
3351
+ summary.push(`DROP VIEW ${viewName} (for recreation)`);
3352
+ }
3353
+ }
3354
+ }
3355
+ const prevTables = new Set(Object.keys(prev.tables || {}));
3356
+ const nextTables = new Set(Object.keys(next.tables || {}));
3240
3357
  const newTableConfigs = [];
3241
3358
  for (const tableName of nextTables) {
3242
3359
  if (!prevTables.has(tableName)) {
3243
- newTableConfigs.push(next[tableName]);
3360
+ newTableConfigs.push(next.tables[tableName]);
3244
3361
  }
3245
3362
  }
3246
3363
  for (const config2 of topoSortConfigs(newTableConfigs)) {
@@ -3249,7 +3366,7 @@ function diffSchemas(prev, next) {
3249
3366
  }
3250
3367
  for (const tableName of prevTables) {
3251
3368
  if (!nextTables.has(tableName)) {
3252
- const config2 = prev[tableName];
3369
+ const config2 = prev.tables[tableName];
3253
3370
  const tbl = config2.schema ? `"${config2.schema}"."${tableName}"` : `"${tableName}"`;
3254
3371
  statements.push(`DROP TABLE IF EXISTS ${tbl};`);
3255
3372
  summary.push(`DROP TABLE ${tableName}`);
@@ -3259,8 +3376,8 @@ function diffSchemas(prev, next) {
3259
3376
  for (const tableName of nextTables) {
3260
3377
  if (!prevTables.has(tableName))
3261
3378
  continue;
3262
- const prevConfig = prev[tableName];
3263
- const nextConfig = next[tableName];
3379
+ const prevConfig = prev.tables[tableName];
3380
+ const nextConfig = next.tables[tableName];
3264
3381
  const prevCols = prevConfig.columns;
3265
3382
  const nextCols = nextConfig.columns;
3266
3383
  const prevColNames = new Set(Object.keys(prevCols));
@@ -3332,6 +3449,19 @@ function diffSchemas(prev, next) {
3332
3449
  }
3333
3450
  }
3334
3451
  }
3452
+ for (const viewName of nextViewNames) {
3453
+ if (!prevViewNames.has(viewName)) {
3454
+ statements.push(generateCreateView(nextViews[viewName]));
3455
+ summary.push(`CREATE VIEW ${viewName}`);
3456
+ } else {
3457
+ const pSql = generateCreateView(prevViews[viewName]);
3458
+ const nSql = generateCreateView(nextViews[viewName]);
3459
+ if (pSql !== nSql) {
3460
+ statements.push(generateCreateView(nextViews[viewName]));
3461
+ summary.push(`CREATE VIEW ${viewName} (recreated)`);
3462
+ }
3463
+ }
3464
+ }
3335
3465
  return { statements, summary, warnings };
3336
3466
  }
3337
3467
  function topoSortConfigs(tables) {
@@ -3359,7 +3489,14 @@ function topoSortConfigs(tables) {
3359
3489
  function diffColumn(prev, next) {
3360
3490
  const changes = [];
3361
3491
  const col2 = `"${next.name}"`;
3492
+ const prevDef = prev.defaultFn ?? (prev.defaultValue !== undefined ? String(prev.defaultValue) : undefined);
3493
+ const nextDef = next.defaultFn ?? (next.defaultValue !== undefined ? String(next.defaultValue) : undefined);
3494
+ let typeChanged = false;
3362
3495
  if (prev.dataType !== next.dataType) {
3496
+ typeChanged = true;
3497
+ if (prevDef !== undefined) {
3498
+ changes.push(`ALTER COLUMN ${col2} DROP DEFAULT`);
3499
+ }
3363
3500
  changes.push(`ALTER COLUMN ${col2} TYPE ${next.dataType.toUpperCase()} USING ${col2}::${next.dataType}`);
3364
3501
  } else {
3365
3502
  const prevLen = prev.length;
@@ -3376,9 +3513,12 @@ function diffColumn(prev, next) {
3376
3513
  if (prev.notNull && !next.notNull) {
3377
3514
  changes.push(`ALTER COLUMN ${col2} DROP NOT NULL`);
3378
3515
  }
3379
- const prevDef = prev.defaultFn ?? (prev.defaultValue !== undefined ? String(prev.defaultValue) : undefined);
3380
- const nextDef = next.defaultFn ?? (next.defaultValue !== undefined ? String(next.defaultValue) : undefined);
3381
- if (prevDef !== nextDef) {
3516
+ if (typeChanged) {
3517
+ if (nextDef !== undefined) {
3518
+ const val = next.defaultFn ?? formatDefault(next.defaultValue, next.dataType);
3519
+ changes.push(`ALTER COLUMN ${col2} SET DEFAULT ${val}`);
3520
+ }
3521
+ } else if (prevDef !== nextDef) {
3382
3522
  if (nextDef !== undefined) {
3383
3523
  const val = next.defaultFn ?? formatDefault(next.defaultValue, next.dataType);
3384
3524
  changes.push(`ALTER COLUMN ${col2} SET DEFAULT ${val}`);
@@ -3421,19 +3561,46 @@ async function runGenerate(config2, name) {
3421
3561
  const metaDir = join3(migrationsDir, "meta");
3422
3562
  await Bun.$`mkdir -p ${migrationsDir}`.quiet();
3423
3563
  await Bun.$`mkdir -p ${metaDir}`.quiet();
3424
- const currentSnapshot = Object.fromEntries(schemas2.map((s2) => [s2.config.name, s2.config]));
3425
- let prevSnapshot = {};
3564
+ const currentSnapshot = { tables: {}, enums: {}, views: {} };
3565
+ for (const s2 of schemas2) {
3566
+ if (s2.type === "table") {
3567
+ currentSnapshot.tables[s2.config.name] = s2.config;
3568
+ } else if (s2.type === "enum") {
3569
+ currentSnapshot.enums[s2.enumName] = { enumName: s2.enumName, enumValues: s2.enumValues };
3570
+ } else if (s2.type === "view") {
3571
+ currentSnapshot.views[s2.config.name] = s2.config;
3572
+ }
3573
+ }
3574
+ let prevSnapshot = { tables: {}, enums: {}, views: {} };
3426
3575
  let isFirstMigration = true;
3427
3576
  try {
3428
3577
  const files = readdirSync(metaDir).filter((f2) => f2.endsWith("_snapshot.json")).sort();
3429
3578
  if (files.length > 0) {
3430
3579
  const latest = files[files.length - 1];
3431
- prevSnapshot = JSON.parse(await Bun.file(join3(metaDir, latest)).text());
3580
+ const parsed = JSON.parse(await Bun.file(join3(metaDir, latest)).text());
3581
+ if (parsed.tables || parsed.enums || parsed.views) {
3582
+ prevSnapshot = {
3583
+ tables: parsed.tables || {},
3584
+ enums: parsed.enums || {},
3585
+ views: parsed.views || {}
3586
+ };
3587
+ } else {
3588
+ prevSnapshot = { tables: parsed, enums: {}, views: {} };
3589
+ }
3432
3590
  isFirstMigration = false;
3433
3591
  } else {
3434
3592
  const legacyFile2 = Bun.file(join3(migrationsDir, ".snapshot.json"));
3435
3593
  if (await legacyFile2.exists()) {
3436
- prevSnapshot = JSON.parse(await legacyFile2.text());
3594
+ const parsed = JSON.parse(await legacyFile2.text());
3595
+ if (parsed.tables || parsed.enums || parsed.views) {
3596
+ prevSnapshot = {
3597
+ tables: parsed.tables || {},
3598
+ enums: parsed.enums || {},
3599
+ views: parsed.views || {}
3600
+ };
3601
+ } else {
3602
+ prevSnapshot = { tables: parsed, enums: {}, views: {} };
3603
+ }
3437
3604
  isFirstMigration = false;
3438
3605
  }
3439
3606
  }
@@ -3453,17 +3620,30 @@ async function runGenerate(config2, name) {
3453
3620
  let summary;
3454
3621
  let warnings = [];
3455
3622
  if (isFirstMigration) {
3456
- const sorted = topoSort(schemas2);
3457
- upStatements = sorted.flatMap((entry) => [
3458
- `-- ${entry.exportName}`,
3459
- generateCreateTable(entry.config, true),
3460
- ``
3461
- ]);
3462
- downStatements = [...sorted].reverse().flatMap((entry) => {
3623
+ const tableSchemas = schemas2.filter((s2) => s2.type === "table");
3624
+ const enumSchemas = schemas2.filter((s2) => s2.type === "enum");
3625
+ const viewSchemas = schemas2.filter((s2) => s2.type === "view");
3626
+ const sorted = topoSort(tableSchemas);
3627
+ upStatements = [];
3628
+ downStatements = [];
3629
+ summary = [];
3630
+ for (const e of enumSchemas) {
3631
+ upStatements.push(`-- ${e.exportName}`, `CREATE TYPE "${e.enumName}" AS ENUM (${e.enumValues.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")});`, ``);
3632
+ downStatements.unshift(`DROP TYPE IF EXISTS "${e.enumName}";`);
3633
+ summary.push(`CREATE TYPE ${e.enumName}`);
3634
+ }
3635
+ for (const entry of sorted) {
3636
+ upStatements.push(`-- ${entry.exportName}`, generateCreateTable(entry.config, true), ``);
3463
3637
  const tbl = entry.config.schema ? `"${entry.config.schema}"."${entry.config.name}"` : `"${entry.config.name}"`;
3464
- return [`DROP TABLE IF EXISTS ${tbl};`];
3465
- });
3466
- summary = sorted.map((s2) => `CREATE TABLE ${s2.config.name}`);
3638
+ downStatements.unshift(`DROP TABLE IF EXISTS ${tbl};`);
3639
+ summary.push(`CREATE TABLE ${entry.config.name}`);
3640
+ }
3641
+ for (const v of viewSchemas) {
3642
+ upStatements.push(`-- ${v.exportName}`, generateCreateView(v.config), ``);
3643
+ const isMat = v.config.materialized ? "MATERIALIZED VIEW" : "VIEW";
3644
+ downStatements.unshift(`DROP ${isMat} IF EXISTS "${v.config.name}";`);
3645
+ summary.push(`CREATE ${isMat} ${v.config.name}`);
3646
+ }
3467
3647
  } else {
3468
3648
  const upDiff = diffSchemas(prevSnapshot, currentSnapshot);
3469
3649
  if (upDiff.statements.length === 0) {
@@ -3595,12 +3775,12 @@ export default defineConfig({
3595
3775
  uuid,
3596
3776
  varchar,
3597
3777
  timestamptz,
3598
- table,
3778
+ pgTable,
3599
3779
  unique,
3600
3780
  index
3601
3781
  } from "@bungres/orm";
3602
3782
 
3603
- export const users = table("users", {
3783
+ export const users = pgTable("users", {
3604
3784
  id: uuid({ primaryKey: true }),
3605
3785
  name: varchar({ length: 255, notNull: true }),
3606
3786
  email: varchar({ length: 255, notNull: true }),
@@ -3736,19 +3916,18 @@ function generateSchemaTS(tableMap, dbSchema) {
3736
3916
  `// Generated at: ${new Date().toISOString()}`,
3737
3917
  ``,
3738
3918
  `import {`,
3739
- ` table,`,
3919
+ ` pgTable,`,
3740
3920
  ` text, varchar, char, integer, bigint, smallint,`,
3741
3921
  ` serial, bigserial, boolean, real, doublePrecision,`,
3742
3922
  ` numeric, decimal, json, jsonb,`,
3743
3923
  ` timestamp, timestamptz, date, time, uuid,`,
3744
3924
  ` bytea, inet,`,
3745
- ` textArray, integerArray, varcharArray, uuidArray,`,
3746
3925
  `} from "@bungres/orm";`,
3747
3926
  ``
3748
3927
  ];
3749
3928
  for (const [, table2] of tableMap) {
3750
3929
  const varName = toCamelCase(table2.tableName);
3751
- lines.push(`export const ${varName} = table("${table2.tableName}", {`);
3930
+ lines.push(`export const ${varName} = pgTable("${table2.tableName}", {`);
3752
3931
  for (const col2 of table2.columns) {
3753
3932
  const colExpr = buildColumnExpression(col2);
3754
3933
  lines.push(` ${toCamelCase(col2.name)}: ${colExpr},`);
@@ -3829,11 +4008,13 @@ function buildColumnExpression(col2) {
3829
4008
  refOpts += `, onUpdate: "${updateRule}"`;
3830
4009
  opts.push(`references: { ${refOpts} }`);
3831
4010
  }
3832
- let builderName = pgTypeToBungresBuilderName(col2);
3833
- if (opts.length > 0) {
3834
- return `${builderName}({ ${opts.join(", ")} })`;
3835
- }
3836
- return `${builderName}()`;
4011
+ let builderResult = pgTypeToBungresBuilderName(col2);
4012
+ let builderName = typeof builderResult === "string" ? builderResult : builderResult.base;
4013
+ let isArray = typeof builderResult !== "string" && builderResult.isArray;
4014
+ let expr = opts.length > 0 ? `${builderName}({ ${opts.join(", ")} })` : `${builderName}()`;
4015
+ if (isArray)
4016
+ expr += `.array()`;
4017
+ return expr;
3837
4018
  }
3838
4019
  function pgTypeToBungresBuilderName(col2) {
3839
4020
  const dt = col2.dataType;
@@ -3879,14 +4060,14 @@ function pgTypeToBungresBuilderName(col2) {
3879
4060
  return "text";
3880
4061
  if (dt === "ARRAY") {
3881
4062
  if (col2.udtName === "_text")
3882
- return "textArray";
4063
+ return { base: "text", isArray: true };
3883
4064
  if (col2.udtName === "_int4" || col2.udtName === "_int8")
3884
- return "integerArray";
4065
+ return { base: "integer", isArray: true };
3885
4066
  if (col2.udtName === "_varchar")
3886
- return "varcharArray";
4067
+ return { base: "varchar", isArray: true };
3887
4068
  if (col2.udtName === "_uuid")
3888
- return "uuidArray";
3889
- return "textArray";
4069
+ return { base: "uuid", isArray: true };
4070
+ return { base: "text", isArray: true };
3890
4071
  }
3891
4072
  return "text";
3892
4073
  }
@@ -3921,11 +4102,29 @@ async function runPush(config2, opts = {}) {
3921
4102
  );
3922
4103
  `);
3923
4104
  const rows = await sql2.unsafe(`SELECT snapshot FROM ${qualifiedPush} ORDER BY id DESC LIMIT 1;`);
3924
- let prevSnapshot = {};
4105
+ let prevSnapshot = { tables: {}, enums: {}, views: {} };
3925
4106
  if (rows.length > 0) {
3926
- prevSnapshot = typeof rows[0].snapshot === "string" ? JSON.parse(rows[0].snapshot) : rows[0].snapshot;
4107
+ const parsed = typeof rows[0].snapshot === "string" ? JSON.parse(rows[0].snapshot) : rows[0].snapshot;
4108
+ if (parsed.tables || parsed.enums || parsed.views) {
4109
+ prevSnapshot = {
4110
+ tables: parsed.tables || {},
4111
+ enums: parsed.enums || {},
4112
+ views: parsed.views || {}
4113
+ };
4114
+ } else {
4115
+ prevSnapshot = { tables: parsed, enums: {}, views: {} };
4116
+ }
4117
+ }
4118
+ const currentSnapshot = { tables: {}, enums: {}, views: {} };
4119
+ for (const s2 of schemas2) {
4120
+ if (s2.type === "table") {
4121
+ currentSnapshot.tables[s2.config.name] = s2.config;
4122
+ } else if (s2.type === "enum") {
4123
+ currentSnapshot.enums[s2.enumName] = { enumName: s2.enumName, enumValues: s2.enumValues };
4124
+ } else if (s2.type === "view") {
4125
+ currentSnapshot.views[s2.config.name] = s2.config;
4126
+ }
3927
4127
  }
3928
- const currentSnapshot = Object.fromEntries(schemas2.map((schemaEntry) => [schemaEntry.config.name, schemaEntry.config]));
3929
4128
  const diff = diffSchemas(prevSnapshot, currentSnapshot);
3930
4129
  if (diff.statements.length === 0) {
3931
4130
  ms.stop("Up to date.");
@@ -3982,7 +4181,7 @@ async function runPush(config2, opts = {}) {
3982
4181
  // src/commands/refresh.ts
3983
4182
  var import_picocolors6 = __toESM(require_picocolors(), 1);
3984
4183
  async function runRefresh(config2) {
3985
- const schemas2 = await loadSchemas(config2.schema);
4184
+ const schemas2 = (await loadSchemas(config2.schema)).filter((s) => s.type === "table");
3986
4185
  if (schemas2.length === 0) {
3987
4186
  console.warn("No table definitions found in schema files.");
3988
4187
  return;
@@ -4116,7 +4315,7 @@ async function runSeed(config2) {
4116
4315
  }
4117
4316
  const s = spinner();
4118
4317
  s.start("Loading schemas for auto-seeding...");
4119
- const schemas2 = await loadSchemas(config2.schema);
4318
+ const schemas2 = (await loadSchemas(config2.schema)).filter((s2) => s2.type === "table");
4120
4319
  if (schemas2.length === 0) {
4121
4320
  s.stop("No schemas.");
4122
4321
  outro("Nothing to seed.");
@@ -4248,7 +4447,8 @@ async function runStatus(config2) {
4248
4447
  var import_picocolors10 = __toESM(require_picocolors(), 1);
4249
4448
 
4250
4449
  // src/commands/template.ts
4251
- var indexHtml = `<!DOCTYPE html>
4450
+ function renderIndexHtml(opts) {
4451
+ return `<!DOCTYPE html>
4252
4452
  <html lang="en">
4253
4453
  <head>
4254
4454
  <meta charset="UTF-8">
@@ -4257,8 +4457,7 @@ var indexHtml = `<!DOCTYPE html>
4257
4457
 
4258
4458
  <link rel="preconnect" href="https://fonts.googleapis.com">
4259
4459
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
4260
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
4261
- <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
4460
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
4262
4461
 
4263
4462
  <script src="https://unpkg.com/htmx.org@2.0.0" defer></script>
4264
4463
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
@@ -4270,38 +4469,17 @@ var indexHtml = `<!DOCTYPE html>
4270
4469
  theme: {
4271
4470
  extend: {
4272
4471
  fontFamily: {
4273
- sans: ['Outfit', 'sans-serif'],
4274
- mono: ['"Fira Code"', 'monospace'],
4472
+ sans: ['Inter', 'sans-serif'],
4473
+ mono: ['"JetBrains Mono"', 'monospace'],
4275
4474
  },
4276
4475
  colors: {
4277
- background: 'var(--background)',
4278
- foreground: 'var(--foreground)',
4279
- card: 'var(--card)',
4280
- 'card-foreground': 'var(--card-foreground)',
4281
- popover: 'var(--popover)',
4282
- 'popover-foreground': 'var(--popover-foreground)',
4283
- primary: 'var(--primary)',
4284
- 'primary-foreground': 'var(--primary-foreground)',
4285
- secondary: 'var(--secondary)',
4286
- 'secondary-foreground': 'var(--secondary-foreground)',
4287
- muted: 'var(--muted)',
4288
- 'muted-foreground': 'var(--muted-foreground)',
4289
- accent: 'var(--accent)',
4290
- 'accent-foreground': 'var(--accent-foreground)',
4291
- destructive: 'var(--destructive)',
4292
- border: 'var(--border)',
4293
- input: 'var(--input)',
4294
- ring: 'var(--ring)',
4295
- sidebar: {
4296
- DEFAULT: 'var(--sidebar)',
4297
- foreground: 'var(--sidebar-foreground)',
4298
- primary: 'var(--sidebar-primary)',
4299
- 'primary-foreground': 'var(--sidebar-primary-foreground)',
4300
- accent: 'var(--sidebar-accent)',
4301
- 'accent-foreground': 'var(--sidebar-accent-foreground)',
4302
- border: 'var(--sidebar-border)',
4303
- ring: 'var(--sidebar-ring)',
4304
- }
4476
+ bg: '#161618',
4477
+ panel: '#1c1c1f',
4478
+ border: '#2c2c2f',
4479
+ text: '#ededed',
4480
+ muted: '#8f8f91',
4481
+ accent: '#00e599',
4482
+ hover: '#28282c'
4305
4483
  }
4306
4484
  }
4307
4485
  }
@@ -4309,265 +4487,319 @@ var indexHtml = `<!DOCTYPE html>
4309
4487
  </script>
4310
4488
 
4311
4489
  <style>
4312
- :root {
4313
- --background: oklch(1 0 0);
4314
- --foreground: oklch(0.148 0.004 228.8);
4315
- --card: oklch(1 0 0);
4316
- --card-foreground: oklch(0.148 0.004 228.8);
4317
- --popover: oklch(1 0 0);
4318
- --popover-foreground: oklch(0.148 0.004 228.8);
4319
- --primary: oklch(0.508 0.118 165.612);
4320
- --primary-foreground: oklch(0.979 0.021 166.113);
4321
- --secondary: oklch(0.967 0.001 286.375);
4322
- --secondary-foreground: oklch(0.21 0.006 285.885);
4323
- --muted: oklch(0.963 0.002 197.1);
4324
- --muted-foreground: oklch(0.56 0.021 213.5);
4325
- --accent: oklch(0.963 0.002 197.1);
4326
- --accent-foreground: oklch(0.218 0.008 223.9);
4327
- --destructive: oklch(0.577 0.245 27.325);
4328
- --border: oklch(0.925 0.005 214.3);
4329
- --input: oklch(0.925 0.005 214.3);
4330
- --ring: oklch(0.723 0.014 214.4);
4331
- --chart-1: oklch(0.845 0.143 164.978);
4332
- --chart-2: oklch(0.696 0.17 162.48);
4333
- --chart-3: oklch(0.596 0.145 163.225);
4334
- --chart-4: oklch(0.508 0.118 165.612);
4335
- --chart-5: oklch(0.432 0.095 166.913);
4336
- --radius: 0.625rem;
4337
- --sidebar: oklch(0.987 0.002 197.1);
4338
- --sidebar-foreground: oklch(0.148 0.004 228.8);
4339
- --sidebar-primary: oklch(0.596 0.145 163.225);
4340
- --sidebar-primary-foreground: oklch(0.979 0.021 166.113);
4341
- --sidebar-accent: oklch(0.963 0.002 197.1);
4342
- --sidebar-accent-foreground: oklch(0.218 0.008 223.9);
4343
- --sidebar-border: oklch(0.925 0.005 214.3);
4344
- --sidebar-ring: oklch(0.723 0.014 214.4);
4345
- }
4346
-
4347
- .dark {
4348
- --background: oklch(0.148 0.004 228.8);
4349
- --foreground: oklch(0.987 0.002 197.1);
4350
- --card: oklch(0.218 0.008 223.9);
4351
- --card-foreground: oklch(0.987 0.002 197.1);
4352
- --popover: oklch(0.218 0.008 223.9);
4353
- --popover-foreground: oklch(0.987 0.002 197.1);
4354
- --primary: oklch(0.432 0.095 166.913);
4355
- --primary-foreground: oklch(0.979 0.021 166.113);
4356
- --secondary: oklch(0.274 0.006 286.033);
4357
- --secondary-foreground: oklch(0.985 0 0);
4358
- --muted: oklch(0.275 0.011 216.9);
4359
- --muted-foreground: oklch(0.723 0.014 214.4);
4360
- --accent: oklch(0.275 0.011 216.9);
4361
- --accent-foreground: oklch(0.987 0.002 197.1);
4362
- --destructive: oklch(0.704 0.191 22.216);
4363
- --border: oklch(1 0 0 / 10%);
4364
- --input: oklch(1 0 0 / 15%);
4365
- --ring: oklch(0.56 0.021 213.5);
4366
- --chart-1: oklch(0.845 0.143 164.978);
4367
- --chart-2: oklch(0.696 0.17 162.48);
4368
- --chart-3: oklch(0.596 0.145 163.225);
4369
- --chart-4: oklch(0.508 0.118 165.612);
4370
- --chart-5: oklch(0.432 0.095 166.913);
4371
- --sidebar: oklch(0.218 0.008 223.9);
4372
- --sidebar-foreground: oklch(0.987 0.002 197.1);
4373
- --sidebar-primary: oklch(0.696 0.17 162.48);
4374
- --sidebar-primary-foreground: oklch(0.262 0.051 172.552);
4375
- --sidebar-accent: oklch(0.275 0.011 216.9);
4376
- --sidebar-accent-foreground: oklch(0.987 0.002 197.1);
4377
- --sidebar-border: oklch(1 0 0 / 10%);
4378
- --sidebar-ring: oklch(0.56 0.021 213.5);
4379
- }
4380
-
4381
- ::-webkit-scrollbar { width: 6px; height: 6px; }
4490
+ body { background-color: #161618; color: #ededed; }
4491
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
4382
4492
  ::-webkit-scrollbar-track { background: transparent; }
4383
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
4384
- ::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); }
4493
+ ::-webkit-scrollbar-thumb { background: #3a3a3e; border-radius: 4px; }
4494
+ ::-webkit-scrollbar-thumb:hover { background: #4a4a4e; }
4385
4495
  ::-webkit-scrollbar-corner { background: transparent; }
4386
4496
  [x-cloak] { display: none !important; }
4387
- .htmx-indicator { opacity: 0; transition: opacity 200ms ease-in; }
4388
- .htmx-request .htmx-indicator { opacity: 1; }
4389
- .htmx-request.htmx-indicator { opacity: 1; }
4497
+
4498
+ .tab-active { background-color: #1c1c1f; border-top: 2px solid #00e599; color: #ededed; }
4499
+ .tab-inactive { background-color: #161618; border-top: 2px solid transparent; color: #8f8f91; }
4500
+
4501
+ table { border-spacing: 0; }
4502
+ th { border-bottom: 1px solid #2c2c2f; border-right: 1px solid #2c2c2f; background: #1c1c1f; font-weight: 500; font-size: 13px; color: #8f8f91; }
4503
+ td { border-bottom: 1px solid #2c2c2f; border-right: 1px solid #2c2c2f; font-size: 13px; }
4504
+ tr:hover td { background-color: #28282c; }
4390
4505
  </style>
4391
4506
  </head>
4392
- <body class="dark bg-background text-foreground font-sans h-screen flex overflow-hidden selection:bg-primary/30">
4393
- <div x-data="{ currentTable: null, pageSize: 25 }" class="flex h-screen w-full">
4394
- <aside class="w-64 bg-sidebar border-r border-sidebar-border flex flex-col z-20 shrink-0 text-sidebar-foreground">
4395
- <div class="p-4 border-b border-sidebar-border flex items-center gap-3 bg-sidebar">
4396
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary">
4397
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
4398
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
4399
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
4400
- </svg>
4401
- <h1 class="text-sm font-semibold tracking-tight m-0">Bungres Studio</h1>
4507
+ <body class="font-sans h-screen flex overflow-hidden text-sm selection:bg-accent/30">
4508
+ <div x-data="studioState()" class="flex h-screen w-full relative">
4509
+
4510
+ <!-- JSON Modal -->
4511
+ <div x-show="jsonModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity" style="display: none;">
4512
+ <div @click.away="jsonModalOpen = false" class="bg-panel border border-border rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[80vh]">
4513
+ <div class="flex items-center justify-between p-3 border-b border-border bg-bg/50">
4514
+ <h3 class="font-medium text-text text-xs flex items-center gap-2">
4515
+ <svg class="w-4 h-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>
4516
+ Data Viewer
4517
+ </h3>
4518
+ <button @click="jsonModalOpen = false" class="text-muted hover:text-text p-1 rounded hover:bg-hover transition-colors"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
4519
+ </div>
4520
+ <div class="p-4 overflow-auto flex-1">
4521
+ <pre class="font-mono text-xs text-text bg-bg p-4 rounded border border-border whitespace-pre-wrap break-all" x-text="jsonModalData"></pre>
4522
+ </div>
4523
+ </div>
4524
+ </div>
4525
+
4526
+ <!-- Sidebar -->
4527
+ <aside class="w-64 bg-bg border-r border-border flex flex-col z-20 shrink-0">
4528
+
4529
+ <!-- Schema Selector -->
4530
+ <div class="p-3 border-b border-border">
4531
+ <select x-model="currentSchema" @change="loadSidebar()" class="w-full bg-panel border border-border text-text text-sm rounded px-2 py-1.5 focus:outline-none focus:border-muted cursor-pointer font-medium">
4532
+ <template x-for="s in schemas" :key="s">
4533
+ <option :value="s" x-text="s"></option>
4534
+ </template>
4535
+ </select>
4536
+ </div>
4537
+
4538
+ <!-- Search Box -->
4539
+ <div class="p-3 border-b border-border bg-bg">
4540
+ <div class="relative">
4541
+ <svg class="w-4 h-4 text-muted absolute left-2.5 top-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
4542
+ <input type="text" x-model="searchQuery" placeholder="Filter tables.." class="w-full bg-panel border border-border rounded pl-8 pr-2 py-1.5 text-sm focus:outline-none focus:border-muted text-text placeholder-muted">
4543
+ </div>
4402
4544
  </div>
4403
- <div class="flex-1 overflow-y-auto p-2" hx-get="/htmx/sidebar" hx-trigger="load">
4404
- <div class="p-4 text-xs text-muted-foreground animate-pulse text-center">Loading schemas...</div>
4545
+
4546
+ <!-- Tables List -->
4547
+ <div class="flex-1 overflow-y-auto pt-2" id="sidebar-content" :hx-get="'/htmx/sidebar?schema=' + currentSchema" hx-trigger="load">
4548
+ <div class="p-4 text-xs text-muted animate-pulse text-center">Loading...</div>
4405
4549
  </div>
4406
4550
  </aside>
4407
- <main class="flex-1 flex flex-col relative w-[calc(100vw-16rem)]">
4408
- <header class="h-14 px-4 border-b border-border flex items-center justify-between bg-card z-10 shrink-0 text-card-foreground">
4409
- <h2 class="text-sm font-semibold tracking-tight flex items-center gap-2">
4410
- <template x-if="currentTable">
4411
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-primary">
4412
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
4413
- <line x1="3" y1="9" x2="21" y2="9"></line>
4414
- <line x1="9" y1="21" x2="9" y2="9"></line>
4415
- </svg>
4416
- </template>
4417
- <template x-if="!currentTable">
4418
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted-foreground">
4419
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
4420
- <line x1="3" y1="9" x2="21" y2="9"></line>
4421
- <line x1="9" y1="21" x2="9" y2="9"></line>
4422
- </svg>
4423
- </template>
4424
- <span x-text="currentTable ? currentTable : 'Select a table'"></span>
4425
- </h2>
4426
- <button x-bind:disabled="!currentTable" class="bg-card border border-border text-card-foreground px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-all hover:bg-accent hover:text-accent-foreground active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 group" x-on:click="$dispatch('refresh-table')">
4427
- <svg class="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4428
- <polyline points="23 4 23 10 17 10"></polyline>
4429
- <polyline points="1 20 1 14 7 14"></polyline>
4430
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
4431
- </svg>
4432
- Refresh
4551
+
4552
+ <!-- Main Content -->
4553
+ <main class="flex-1 flex flex-col min-w-0 bg-panel">
4554
+ <!-- Tabs Header -->
4555
+ <header class="h-10 border-b border-border flex items-center bg-bg shrink-0 overflow-x-auto overflow-y-hidden">
4556
+ <template x-for="tab in tabs" :key="tab.id">
4557
+ <div
4558
+ @click="activeTab = tab.id"
4559
+ :class="activeTab === tab.id ? 'tab-active' : 'tab-inactive hover:bg-panel hover:text-text'"
4560
+ class="h-full flex items-center gap-2 pl-4 pr-2 cursor-pointer border-r border-border min-w-max transition-colors group relative select-none"
4561
+ >
4562
+ <!-- Icon -->
4563
+ <template x-if="tab.type === 'table'">
4564
+ <svg class="w-4 h-4 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>
4565
+ </template>
4566
+ <template x-if="tab.type === 'query'">
4567
+ <svg class="w-4 h-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>
4568
+ </template>
4569
+
4570
+ <span x-text="tab.title" class="font-medium text-xs mr-2"></span>
4571
+
4572
+ <!-- Close Button -->
4573
+ <button @click.stop="closeTab(tab.id)" class="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-hover text-muted hover:text-text transition-opacity">
4574
+ <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
4575
+ </button>
4576
+ </div>
4577
+ </template>
4578
+ <button @click="openQueryEditor()" class="h-full px-3 text-muted hover:text-text hover:bg-panel flex items-center justify-center transition-colors">
4579
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
4433
4580
  </button>
4434
4581
  </header>
4435
- <div class="flex-1 overflow-auto relative z-[1] text-left bg-background">
4436
- <div id="data-container" class="h-full w-full flex flex-col" x-on:refresh-table.window="if(currentTable) htmx.ajax('GET', '/htmx/tables/' + currentTable + '?limit=' + pageSize, {target: '#data-container'})">
4437
- <div class="flex flex-col items-center justify-center h-full w-full text-muted-foreground animate-[fadeIn_0.5s_ease-out]">
4438
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-16 h-16 mb-4 text-muted">
4439
- <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
4440
- <polyline points="14 2 14 8 20 8"></polyline>
4441
- </svg>
4442
- <h3 class="text-foreground m-0 mb-2 text-lg">No Table Selected</h3>
4443
- <p class="text-sm">Choose a table from the sidebar to view its data</p>
4582
+
4583
+ <!-- Active Tab Content Container -->
4584
+ <div class="flex-1 relative overflow-hidden bg-panel">
4585
+
4586
+ <!-- Empty State -->
4587
+ <div x-show="tabs.length === 0" class="absolute inset-0 flex flex-col items-center justify-center text-muted">
4588
+ <div class="w-12 h-12 rounded-lg bg-hover border border-border flex items-center justify-center mb-4">
4589
+ <svg class="w-6 h-6 text-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>
4444
4590
  </div>
4591
+ <p class="text-sm">Select a table to view</p>
4445
4592
  </div>
4593
+
4594
+ <!-- Render Tabs -->
4595
+ <template x-for="tab in tabs" :key="tab.id">
4596
+ <div x-show="activeTab === tab.id" class="absolute inset-0 flex flex-col h-full w-full">
4597
+
4598
+ <!-- Table View -->
4599
+ <template x-if="tab.type === 'table'">
4600
+ <div class="flex flex-col h-full w-full">
4601
+ <!-- Table Header Toolbars -->
4602
+ <div class="h-12 border-b border-border flex items-center justify-between px-3 shrink-0 bg-panel">
4603
+ <div class="flex items-center gap-2">
4604
+ <span class="text-xs text-muted font-medium ml-1 flex items-center gap-2">
4605
+ <svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>
4606
+ <span x-text="tab.title"></span>
4607
+ </span>
4608
+ </div>
4609
+ <!-- HTMX will swap the pagination controls into this div -->
4610
+ <div :id="'pagination-' + tab.id" class="flex items-center gap-2"></div>
4611
+ </div>
4612
+
4613
+ <!-- Table Data Grid Container -->
4614
+ <div class="flex-1 overflow-auto bg-bg" :id="'data-' + tab.id"
4615
+ x-init="htmx.ajax('GET', '/htmx/tables/' + tab.name + '?schema=' + tab.schema + '&tabId=' + tab.id, {target: '#data-' + tab.id})">
4616
+ <div class="p-8 text-center text-muted">Loading data...</div>
4617
+ </div>
4618
+ </div>
4619
+ </template>
4620
+
4621
+ <!-- Query Editor View -->
4622
+ <template x-if="tab.type === 'query'">
4623
+ <div class="flex flex-col h-full w-full" x-data="{ query: 'SELECT 1;' }">
4624
+ <div class="h-12 border-b border-border flex items-center px-3 shrink-0 gap-2 bg-panel">
4625
+ <button
4626
+ @click="htmx.ajax('POST', '/htmx/query', { target: '#query-result-' + tab.id, values: { query: query } })"
4627
+ class="bg-accent text-bg px-3 py-1.5 rounded text-xs font-semibold hover:opacity-90 flex items-center gap-1.5 transition-opacity"
4628
+ >
4629
+ <svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg> Run
4630
+ </button>
4631
+ <span class="text-muted text-[10px] font-mono ml-2">Press Ctrl+Enter to run</span>
4632
+ </div>
4633
+ <div class="flex-none h-48 border-b border-border relative">
4634
+ <textarea
4635
+ x-model="query"
4636
+ @keydown.ctrl.enter.prevent="htmx.ajax('POST', '/htmx/query', { target: '#query-result-' + tab.id, values: { query: query } })"
4637
+ class="absolute inset-0 w-full h-full bg-bg text-text font-mono text-sm p-4 focus:outline-none resize-none"
4638
+ placeholder="SELECT 1;"
4639
+ ></textarea>
4640
+ </div>
4641
+ <div class="flex-1 overflow-auto bg-bg flex flex-col" :id="'query-result-' + tab.id">
4642
+ <div class="h-full flex items-center justify-center text-muted">
4643
+ <div class="flex flex-col items-center">
4644
+ <svg class="w-6 h-6 mb-2 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
4645
+ <span class="text-xs">Run a query to see results</span>
4646
+ </div>
4647
+ </div>
4648
+ </div>
4649
+ </div>
4650
+ </template>
4651
+
4652
+ </div>
4653
+ </template>
4446
4654
  </div>
4447
4655
  </main>
4448
4656
  </div>
4449
4657
  <script>
4450
- document.addEventListener('alpine:init', () => {})
4658
+ const INITIAL_SCHEMAS = ${JSON.stringify(opts.schemas)};
4659
+ const INITIAL_CURRENT_SCHEMA = ${JSON.stringify(opts.currentSchema)};
4660
+
4661
+ function studioState() {
4662
+ return {
4663
+ schemas: INITIAL_SCHEMAS,
4664
+ currentSchema: INITIAL_CURRENT_SCHEMA,
4665
+ searchQuery: '',
4666
+ tabs: [],
4667
+ activeTab: null,
4668
+ queryEditorCount: 0,
4669
+ jsonModalOpen: false,
4670
+ jsonModalData: '',
4671
+
4672
+ init() {
4673
+ this.$watch('activeTab', (value) => {
4674
+ const tab = this.tabs.find(t => t.id === value);
4675
+ if (tab && tab.schema && tab.schema !== this.currentSchema) {
4676
+ this.currentSchema = tab.schema;
4677
+ this.loadSidebar();
4678
+ }
4679
+ });
4680
+
4681
+ // Listen for table clicks from the sidebar
4682
+ window.addEventListener('open-table', (e) => {
4683
+ const tableName = e.detail.tableName;
4684
+ const schema = this.currentSchema;
4685
+ const tabId = 'table_' + schema + '_' + tableName;
4686
+
4687
+ if (!this.tabs.find(t => t.id === tabId)) {
4688
+ this.tabs.push({ id: tabId, type: 'table', title: tableName, name: tableName, schema: schema });
4689
+ }
4690
+ this.activeTab = tabId;
4691
+ });
4692
+
4693
+ window.addEventListener('open-json-modal', (e) => {
4694
+ try {
4695
+ const str = atob(e.detail);
4696
+ this.jsonModalData = JSON.stringify(JSON.parse(str), null, 2);
4697
+ } catch(err) {
4698
+ this.jsonModalData = 'Invalid JSON data';
4699
+ }
4700
+ this.jsonModalOpen = true;
4701
+ });
4702
+ },
4703
+
4704
+ loadSidebar() {
4705
+ htmx.ajax('GET', '/htmx/sidebar?schema=' + this.currentSchema, {target: '#sidebar-content'});
4706
+ },
4707
+
4708
+ openQueryEditor() {
4709
+ this.queryEditorCount++;
4710
+ const tabId = 'query_' + this.queryEditorCount;
4711
+ this.tabs.push({ id: tabId, type: 'query', title: 'Query Editor ' + (this.queryEditorCount > 1 ? this.queryEditorCount : ''), schema: this.currentSchema });
4712
+ this.activeTab = tabId;
4713
+ },
4714
+
4715
+ closeTab(id) {
4716
+ const idx = this.tabs.findIndex(t => t.id === id);
4717
+ if (idx === -1) return;
4718
+
4719
+ this.tabs.splice(idx, 1);
4720
+ if (this.activeTab === id) {
4721
+ if (this.tabs.length > 0) {
4722
+ this.activeTab = this.tabs[Math.max(0, idx - 1)].id;
4723
+ } else {
4724
+ this.activeTab = null;
4725
+ }
4726
+ }
4727
+ }
4728
+ }
4729
+ }
4451
4730
  </script>
4452
4731
  </body>
4453
4732
  </html>`;
4733
+ }
4454
4734
 
4455
4735
  // src/commands/studio.ts
4456
4736
  async function runStudio(config2) {
4457
- const schemas2 = await loadSchemas(config2.schema);
4737
+ const schemas2 = (await loadSchemas(config2.schema)).filter((s) => s.type === "table");
4458
4738
  if (schemas2.length === 0) {
4459
- console.warn("No table definitions found in schema files.");
4460
- return;
4739
+ console.warn("No table definitions found in schema files. Connecting anyway to browse DB...");
4461
4740
  }
4462
4741
  const schemaObj2 = {};
4463
4742
  for (const s of schemas2) {
4464
4743
  schemaObj2[s.exportName] = s.table;
4465
4744
  }
4466
4745
  const db2 = bungres({ url: config2.dbUrl, schema: schemaObj2 });
4746
+ let allSchemas = ["public"];
4747
+ try {
4748
+ const res = await db2.execute(rawSql(`SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name != 'information_schema'`));
4749
+ if (Array.isArray(res) && res.length > 0) {
4750
+ allSchemas = res.map((r2) => r2.schema_name);
4751
+ }
4752
+ } catch (e) {}
4467
4753
  const port = Bun.env.PORT ? parseInt(Bun.env.PORT, 10) : 5555;
4468
4754
  const server = Bun.serve({
4469
4755
  port,
4470
4756
  async fetch(req) {
4471
4757
  const url = new URL(req.url);
4472
- if (req.method === "GET" && url.pathname === "/api/tables") {
4473
- const tables = schemas2.map((s) => {
4474
- const fkColumns = new Set;
4475
- if (s.config.foreignKeys) {
4476
- s.config.foreignKeys.forEach((fk) => {
4477
- fk.columns.forEach((col2) => fkColumns.add(col2));
4478
- });
4479
- }
4480
- const columns = Object.entries(s.config.columns).map(([key, col2]) => {
4481
- const fk = col2.references ? {
4482
- table: col2.references.table,
4483
- column: col2.references.column
4484
- } : fkColumns.has(col2.name) ? { isForeignKey: true } : null;
4485
- return {
4486
- name: col2.name,
4487
- type: col2.dataType,
4488
- primaryKey: col2.primaryKey,
4489
- foreignKey: fk
4490
- };
4491
- });
4492
- return {
4493
- name: s.config.name,
4494
- exportName: s.exportName,
4495
- columns,
4496
- foreignKeys: s.config.foreignKeys || []
4497
- };
4498
- });
4499
- return new Response(JSON.stringify(tables), {
4500
- headers: { "Content-Type": "application/json" }
4501
- });
4502
- }
4503
- if (req.method === "GET" && url.pathname.startsWith("/api/tables/") && url.pathname.endsWith("/data")) {
4504
- const tableName = url.pathname.split("/")[3];
4505
- const schema = schemas2.find((s) => s.config.name === tableName);
4506
- if (!schema) {
4507
- return new Response("Table not found", { status: 404 });
4508
- }
4509
- try {
4510
- const page = parseInt(url.searchParams.get("page") || "1", 10);
4511
- const limit = parseInt(url.searchParams.get("limit") || "50", 10);
4512
- const offset = (page - 1) * limit;
4513
- const countResult = await db2.select({ count: schema.table }).from(schema.table);
4514
- const total = Array.isArray(countResult) ? countResult.length : 0;
4515
- const data = await db2.select().from(schema.table).limit(limit).offset(offset);
4516
- return new Response(JSON.stringify({
4517
- data,
4518
- total,
4519
- page,
4520
- limit,
4521
- totalPages: Math.ceil(total / limit)
4522
- }), {
4523
- headers: { "Content-Type": "application/json" }
4524
- });
4525
- } catch (e) {
4526
- return new Response(JSON.stringify({ error: e.message }), {
4527
- status: 500,
4528
- headers: { "Content-Type": "application/json" }
4529
- });
4530
- }
4531
- }
4532
- if (req.method === "POST" && url.pathname === "/api/editor/open") {
4758
+ if (req.method === "GET" && url.pathname === "/htmx/sidebar") {
4759
+ const currentSchema = url.searchParams.get("schema") || config2.dbSchema || "public";
4760
+ let items = [];
4533
4761
  try {
4534
- const body = await req.json();
4535
- const tableName = body.tableName;
4536
- const schema = schemas2.find((s) => s.config.name === tableName);
4537
- if (schema && schema.filePath) {
4538
- console.log(`Attempting to open ${schema.filePath} in editor...`);
4539
- Bun.openInEditor(schema.filePath, { editor: "vscode" });
4540
- return new Response(JSON.stringify({
4541
- success: true,
4542
- filePath: schema.filePath
4543
- }), {
4544
- headers: { "Content-Type": "application/json" }
4545
- });
4762
+ const query = `
4763
+ SELECT c.relname as name, c.reltuples as count, c.relkind as type
4764
+ FROM pg_class c
4765
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
4766
+ WHERE n.nspname = '${currentSchema}'
4767
+ AND c.relkind IN ('r', 'v', 'm')
4768
+ ORDER BY c.relname
4769
+ `;
4770
+ const res = await db2.execute(rawSql(query));
4771
+ if (Array.isArray(res)) {
4772
+ items = res;
4546
4773
  }
4547
- return new Response("Schema not found", { status: 404 });
4548
4774
  } catch (e) {
4549
- return new Response("Invalid request", { status: 400 });
4775
+ items = schemas2.map((s) => ({ name: s.config.name, count: 0, type: "r" }));
4550
4776
  }
4551
- }
4552
- if (req.method === "GET" && url.pathname === "/htmx/sidebar") {
4553
- let html = '<ul class="flex flex-col gap-0.5 p-2 m-0 list-none">';
4554
- schemas2.forEach((s) => {
4555
- const tableName = s.config.name;
4777
+ const formatCount = (n2) => {
4778
+ if (n2 >= 1e6)
4779
+ return (n2 / 1e6).toFixed(1) + "M";
4780
+ if (n2 >= 1000)
4781
+ return (n2 / 1000).toFixed(1) + "k";
4782
+ return Math.floor(n2).toString();
4783
+ };
4784
+ let html = '<div class="px-3 py-2 text-[10px] font-semibold text-muted tracking-wider flex justify-between items-center"><div class="flex items-center gap-1.5"><svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg> TABLES</div></div>';
4785
+ html += '<ul class="flex flex-col gap-0.5 p-2 m-0 list-none">';
4786
+ items.forEach((item) => {
4787
+ let icon = '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>';
4788
+ if (item.type === "v")
4789
+ icon = '<svg class="w-4 h-4 text-purple-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>';
4790
+ if (item.type === "m")
4791
+ icon = '<svg class="w-4 h-4 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>';
4556
4792
  html += `
4557
- <li>
4793
+ <li x-show="searchQuery === '' || '${item.name}'.toLowerCase().includes(searchQuery.toLowerCase())">
4558
4794
  <button
4559
- hx-get="/htmx/tables/${tableName}?page=1&limit=25"
4560
- hx-target="#data-container"
4561
- @click="currentTable = '${tableName}'; pageSize = 25"
4562
- :class="currentTable === '${tableName}' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'"
4563
- class="w-full text-left px-3 py-2 rounded-md text-sm font-medium flex items-center gap-2 transition-colors focus:outline-none"
4795
+ @click="$dispatch('open-table', { tableName: '${item.name}' })"
4796
+ class="w-full text-left px-2 py-1 rounded text-sm font-medium flex items-center justify-between transition-colors focus:outline-none hover:bg-hover hover:text-text group text-muted"
4564
4797
  >
4565
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4566
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
4567
- <line x1="3" y1="9" x2="21" y2="9"></line>
4568
- <line x1="9" y1="21" x2="9" y2="9"></line>
4569
- </svg>
4570
- ${tableName}
4798
+ <div class="flex items-center gap-2 truncate">
4799
+ ${icon}
4800
+ <span class="truncate">${item.name}</span>
4801
+ </div>
4802
+ <span class="text-[10px] font-mono text-muted/50 group-hover:text-muted">${formatCount(Math.max(0, item.count))}</span>
4571
4803
  </button>
4572
4804
  </li>
4573
4805
  `;
@@ -4577,140 +4809,215 @@ async function runStudio(config2) {
4577
4809
  }
4578
4810
  if (req.method === "GET" && url.pathname.startsWith("/htmx/tables/")) {
4579
4811
  const tableName = url.pathname.split("/")[3];
4580
- const schema = schemas2.find((s) => s.config.name === tableName);
4581
- if (!schema) {
4582
- return new Response(`<div class="p-4 text-red-500">Table not found</div>`, { status: 404, headers: { "Content-Type": "text/html" } });
4583
- }
4812
+ const reqSchema = url.searchParams.get("schema") || config2.dbSchema || "public";
4813
+ const tabId = url.searchParams.get("tabId") || `table_${tableName}`;
4584
4814
  try {
4585
4815
  const page = parseInt(url.searchParams.get("page") || "1", 10);
4586
4816
  const limit = parseInt(url.searchParams.get("limit") || "25", 10);
4587
4817
  const offset = (page - 1) * limit;
4588
- const countResult = await db2.select({ count: schema.table }).from(schema.table);
4589
- const total = Array.isArray(countResult) ? countResult.length : 0;
4818
+ let countResult;
4819
+ let data = [];
4820
+ const tsSchema = schemas2.find((s) => s.config.name === tableName);
4821
+ let colConfigs = {};
4822
+ let fkColumns = new Set;
4823
+ try {
4824
+ if (tsSchema) {
4825
+ colConfigs = tsSchema.config.columns || {};
4826
+ if (tsSchema.config.foreignKeys) {
4827
+ tsSchema.config.foreignKeys.forEach((fk) => fk.columns.forEach((col2) => fkColumns.add(col2)));
4828
+ }
4829
+ const countRes = await db2.select({ count: tsSchema.table }).from(tsSchema.table);
4830
+ countResult = Array.isArray(countRes) ? [{ count: countRes.length }] : [{ count: 0 }];
4831
+ data = await db2.select().from(tsSchema.table).limit(limit).offset(offset);
4832
+ } else {
4833
+ countResult = await db2.execute(rawSql(`SELECT COUNT(*) as count FROM "${reqSchema}"."${tableName}"`));
4834
+ data = await db2.execute(rawSql(`SELECT * FROM "${reqSchema}"."${tableName}" LIMIT ${limit} OFFSET ${offset}`));
4835
+ const colsRes = await db2.execute(rawSql(`SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = '${reqSchema}' AND table_name = '${tableName}'`));
4836
+ if (Array.isArray(colsRes)) {
4837
+ colsRes.forEach((c2) => {
4838
+ colConfigs[c2.column_name] = { dataType: c2.data_type };
4839
+ });
4840
+ }
4841
+ }
4842
+ } catch (e) {
4843
+ return new Response(`<div class="p-4 text-red-500">Table not found or query error</div>`, { status: 404, headers: { "Content-Type": "text/html" } });
4844
+ }
4845
+ const total = Array.isArray(countResult) && countResult.length > 0 ? parseInt(countResult[0].count, 10) : 0;
4590
4846
  const totalPages = Math.ceil(total / limit) || 1;
4591
- const data = await db2.select().from(schema.table).limit(limit).offset(offset);
4847
+ if (!Array.isArray(data))
4848
+ data = [];
4592
4849
  const formatValue = (val) => {
4593
4850
  if (val === null || val === undefined)
4594
- return '<span class="italic text-muted-foreground">null</span>';
4595
- if (typeof val === "number")
4596
- return `<span class="text-foreground">${val}</span>`;
4597
- if (typeof val === "boolean")
4598
- return `<span class="text-foreground">${val}</span>`;
4599
- if (val instanceof Date)
4600
- return `<span class="text-foreground">${val.toISOString()}</span>`;
4601
- if (typeof val === "object")
4602
- return `<span class="text-foreground font-mono text-xs">${JSON.stringify(val).replace(/&/g, "&amp;").replace(/</g, "&lt;")}</span>`;
4603
- return `<span class="text-foreground">${String(val).replace(/&/g, "&amp;").replace(/</g, "&lt;")}</span>`;
4851
+ return '<span class="italic text-muted/50">NULL</span>';
4852
+ if (typeof val === "object" && !(val instanceof Date)) {
4853
+ const rawJson = JSON.stringify(val);
4854
+ const b64 = Buffer.from(rawJson).toString("base64");
4855
+ const safeJson = rawJson.replace(/&/g, "&amp;").replace(/</g, "&lt;");
4856
+ return `<button onclick="window.dispatchEvent(new CustomEvent('open-json-modal', { detail: '${b64}' }))" class="text-text font-mono text-xs hover:underline text-left truncate w-full max-w-[250px] block cursor-pointer" title="Click to view formatted data">${safeJson}</button>`;
4857
+ }
4858
+ return `<span class="text-text">${String(val).replace(/&/g, "&amp;").replace(/</g, "&lt;")}</span>`;
4604
4859
  };
4605
- if (data.length === 0) {
4606
- return new Response(`
4607
- <div class="flex flex-col items-center justify-center h-full w-full text-muted-foreground animate-[fadeIn_0.5s_ease-out]">
4608
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-16 h-16 mb-4 text-muted">
4609
- <circle cx="12" cy="12" r="10"></circle>
4610
- <line x1="12" y1="8" x2="12" y2="12"></line>
4611
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
4612
- </svg>
4613
- <h3 class="text-foreground m-0 mb-2 text-lg">Table is Empty</h3>
4614
- <p class="text-sm">No records found in "${tableName}"</p>
4860
+ const columns = tsSchema ? Object.keys(colConfigs) : Object.keys(data[0] || {});
4861
+ let html = '<div class="h-full w-full overflow-auto bg-bg">';
4862
+ if (data.length === 0 && columns.length === 0) {
4863
+ html += '<div class="p-8 text-center text-muted">Table is Empty</div></div>';
4864
+ } else {
4865
+ html += '<table class="text-left border-collapse whitespace-nowrap min-w-max">';
4866
+ html += "<thead><tr>";
4867
+ html += `<th class="w-10 px-3 py-2 sticky top-0 z-10 text-center bg-panel"><input type="checkbox" @change="document.querySelectorAll('#data-${tabId} .row-checkbox').forEach(cb => cb.checked = $event.target.checked)" class="w-3.5 h-3.5 rounded border-muted bg-transparent cursor-pointer"></th>`;
4868
+ columns.forEach((col2) => {
4869
+ const colConfig = colConfigs[col2];
4870
+ let typeLabel = colConfig ? colConfig.dataType : "unknown";
4871
+ let badges = "";
4872
+ if (colConfig?.primaryKey) {
4873
+ badges += '<span class="ml-1.5 text-[9px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded uppercase font-bold" title="Primary Key">PK</span>';
4874
+ }
4875
+ if (tsSchema && tsSchema.config.indexes) {
4876
+ const isIndexed = tsSchema.config.indexes.some((idx) => idx.columns.includes(col2));
4877
+ if (isIndexed)
4878
+ badges += '<span class="ml-1.5 text-[9px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded uppercase font-bold" title="Indexed">IDX</span>';
4879
+ }
4880
+ if (colConfig?.unique)
4881
+ badges += '<span class="ml-1.5 text-[9px] bg-amber-500/20 text-amber-400 px-1 py-0.5 rounded uppercase font-bold" title="Unique">UQ</span>';
4882
+ if (fkColumns.has(col2))
4883
+ badges += '<span class="ml-1.5 text-[9px] bg-purple-500/20 text-purple-400 px-1 py-0.5 rounded uppercase font-bold" title="Foreign Key">FK</span>';
4884
+ html += `<th class="px-4 py-2 font-medium sticky top-0 z-10 whitespace-nowrap bg-panel text-muted hover:text-text cursor-pointer transition-colors border-b border-r border-border">
4885
+ <div class="flex items-center gap-2">
4886
+ <span class="flex items-center">${col2}${badges}</span>
4887
+ <span class="text-[10px] font-mono opacity-50">${typeLabel}</span>
4888
+ </div>
4889
+ </th>`;
4890
+ });
4891
+ html += "</tr></thead><tbody>";
4892
+ data.forEach((row) => {
4893
+ html += '<tr class="hover:bg-hover transition-colors">';
4894
+ html += '<td class="px-3 py-1.5 text-center bg-bg border-b border-r border-border"><input type="checkbox" class="row-checkbox w-3.5 h-3.5 rounded border-muted bg-transparent cursor-pointer"></td>';
4895
+ columns.forEach((col2) => {
4896
+ html += `<td class="px-4 py-1.5 max-w-[300px] overflow-hidden text-ellipsis font-mono border-b border-r border-border bg-bg">${formatValue(row[col2])}</td>`;
4897
+ });
4898
+ html += "</tr>";
4899
+ });
4900
+ html += "</tbody></table></div>";
4901
+ }
4902
+ const startRecord = total === 0 ? 0 : (page - 1) * limit + 1;
4903
+ const endRecord = Math.min(page * limit, total);
4904
+ let paginationHtml = `
4905
+ <div id="pagination-${tabId}" hx-swap-oob="true" class="flex items-center gap-4 text-xs text-muted font-mono">
4906
+ <span>${startRecord}-${endRecord} of ${total}</span>
4907
+
4908
+ <div class="flex items-center gap-3">
4909
+ <div class="relative flex items-center border border-border rounded bg-panel overflow-hidden group">
4910
+ <select
4911
+ class="bg-transparent text-text pl-2 pr-6 py-0.5 focus:outline-none cursor-pointer appearance-none text-center relative z-10 w-full"
4912
+ @change="htmx.ajax('GET', '/htmx/tables/${tableName}?schema=${reqSchema}&tabId=${tabId}&page=1&limit=' + $event.target.value, {target: '#data-${tabId}'})"
4913
+ >
4914
+ <option class="bg-bg text-text" style="background-color: #161618; color: #ededed;" value="10" ${limit === 10 ? "selected" : ""}>10</option>
4915
+ <option class="bg-bg text-text" style="background-color: #161618; color: #ededed;" value="25" ${limit === 25 ? "selected" : ""}>25</option>
4916
+ <option class="bg-bg text-text" style="background-color: #161618; color: #ededed;" value="50" ${limit === 50 ? "selected" : ""}>50</option>
4917
+ <option class="bg-bg text-text" style="background-color: #161618; color: #ededed;" value="100" ${limit === 100 ? "selected" : ""}>100</option>
4918
+ </select>
4919
+ <div class="absolute right-1 text-muted pointer-events-none z-0 group-hover:text-text transition-colors">
4920
+ <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>
4921
+ </div>
4922
+ </div>
4923
+
4924
+ <div class="flex items-center border border-border rounded bg-panel overflow-hidden">
4925
+ <div class="px-2 py-0.5 text-text text-center min-w-[30px]">${page}</div>
4926
+ <div class="px-1 py-0.5 text-muted border-l border-border text-[10px] flex items-center justify-center pointer-events-none bg-panel">
4927
+ of ${totalPages}
4928
+ </div>
4929
+ <button
4930
+ ${page <= 1 ? "disabled" : ""}
4931
+ hx-get="/htmx/tables/${tableName}?schema=${reqSchema}&tabId=${tabId}&page=${page - 1}&limit=${limit}"
4932
+ hx-target="#data-${tabId}"
4933
+ class="px-2 py-1 hover:bg-hover hover:text-text disabled:opacity-50 disabled:cursor-not-allowed border-l border-r border-border transition-colors flex items-center justify-center"
4934
+ ><svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"></polyline></svg></button>
4935
+ <button
4936
+ ${page >= totalPages ? "disabled" : ""}
4937
+ hx-get="/htmx/tables/${tableName}?schema=${reqSchema}&tabId=${tabId}&page=${page + 1}&limit=${limit}"
4938
+ hx-target="#data-${tabId}"
4939
+ class="px-2 py-1 hover:bg-hover hover:text-text disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
4940
+ ><svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg></button>
4941
+ </div>
4942
+ </div>
4943
+ </div>
4944
+ `;
4945
+ return new Response(html + paginationHtml, { headers: { "Content-Type": "text/html" } });
4946
+ } catch (e) {
4947
+ return new Response(`
4948
+ <div class="p-6">
4949
+ <div class="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg">
4950
+ <h3 class="font-semibold mb-1">Error Loading Data</h3>
4951
+ <p class="text-sm opacity-80">${e.message}</p>
4615
4952
  </div>
4616
- `, { headers: { "Content-Type": "text/html" } });
4953
+ </div>
4954
+ `, { headers: { "Content-Type": "text/html" } });
4955
+ }
4956
+ }
4957
+ if (req.method === "POST" && url.pathname === "/htmx/query") {
4958
+ try {
4959
+ const body = await req.formData();
4960
+ const query = body.get("query")?.toString() || "";
4961
+ if (!query.trim()) {
4962
+ return new Response('<div class="p-6 text-muted">No query provided.</div>', { headers: { "Content-Type": "text/html" } });
4963
+ }
4964
+ const startMs = Date.now();
4965
+ const res = await db2.execute(rawSql(query));
4966
+ const duration2 = Date.now() - startMs;
4967
+ const data = Array.isArray(res) ? res : [res];
4968
+ if (data.length === 0 || data.length === 1 && typeof data[0] === "object" && Object.keys(data[0]).length === 0) {
4969
+ return new Response(`<div class="p-4 text-accent text-xs font-mono border-b border-border">Query executed successfully in ${duration2}ms. No data returned.</div>`, { headers: { "Content-Type": "text/html" } });
4617
4970
  }
4618
4971
  const columns = Object.keys(data[0] || {});
4619
- let html = '<div class="flex flex-col h-full w-full bg-background">';
4620
- html += '<div class="flex-1 overflow-auto">';
4621
- html += '<table class="text-left border-collapse text-sm whitespace-nowrap">';
4972
+ const formatValue = (val) => {
4973
+ if (val === null || val === undefined)
4974
+ return '<span class="italic text-muted/50">NULL</span>';
4975
+ if (typeof val === "object" && !(val instanceof Date)) {
4976
+ const rawJson = JSON.stringify(val);
4977
+ const b64 = Buffer.from(rawJson).toString("base64");
4978
+ const safeJson = rawJson.replace(/&/g, "&amp;").replace(/</g, "&lt;");
4979
+ return `<button onclick="window.dispatchEvent(new CustomEvent('open-json-modal', { detail: '${b64}' }))" class="text-text font-mono text-xs hover:underline text-left truncate w-full max-w-[250px] block cursor-pointer" title="Click to view formatted data">${safeJson}</button>`;
4980
+ }
4981
+ return `<span class="text-text">${String(val).replace(/&/g, "&amp;").replace(/</g, "&lt;")}</span>`;
4982
+ };
4983
+ let html = '<div class="h-full w-full overflow-auto bg-bg">';
4984
+ html += '<table class="text-left border-collapse whitespace-nowrap min-w-max">';
4622
4985
  html += "<thead><tr>";
4623
4986
  columns.forEach((col2) => {
4624
- const colConfig = schema.config.columns ? schema.config.columns[col2] : null;
4625
- let typeLabel = colConfig ? colConfig.dataType : "unknown";
4626
- let indexLabel = "";
4627
- if (colConfig?.primaryKey) {
4628
- indexLabel += '<span class="ml-1 text-[10px] bg-emerald-500/20 text-emerald-500 px-1 rounded uppercase" title="Primary Key">PK</span>';
4629
- }
4630
- if (colConfig?.unique) {
4631
- indexLabel += '<span class="ml-1 text-[10px] bg-amber-500/20 text-amber-500 px-1 rounded uppercase" title="Unique">UQ</span>';
4632
- }
4633
- if (colConfig?.references) {
4634
- indexLabel += '<span class="ml-1 text-[10px] bg-yellow-500/20 text-yellow-500 px-1 rounded uppercase" title="Foreign Key">FK</span>';
4635
- }
4636
- if (schema.config.indexes) {
4637
- const isIndexed = schema.config.indexes.some((idx) => idx.columns.includes(col2));
4638
- if (isIndexed) {
4639
- indexLabel += '<span class="ml-1 text-[10px] bg-blue-500/20 text-blue-500 px-1 rounded uppercase" title="Indexed">IDX</span>';
4640
- }
4641
- }
4642
- html += `<th class="bg-card border-b border-r border-border px-4 py-2.5 font-medium text-muted-foreground sticky top-0 z-10">
4643
- <div class="flex flex-col gap-0.5">
4644
- <span class="text-foreground">${col2}</span>
4645
- <div class="flex items-center flex-wrap gap-1 mt-0.5">
4646
- <span class="text-[10px] font-mono text-muted-foreground/70 mr-1">${typeLabel}</span>
4647
- ${indexLabel}
4648
- </div>
4987
+ const row0 = data.length > 0 ? data[0] : null;
4988
+ const typeLabel = row0 && row0[col2] != null ? typeof row0[col2] : "unknown";
4989
+ html += `<th class="px-4 py-2 font-medium sticky top-0 z-10 whitespace-nowrap bg-panel text-muted hover:text-text cursor-pointer transition-colors border-b border-r border-border">
4990
+ <div class="flex items-center gap-2">
4991
+ <span class="text-text">${col2}</span>
4992
+ <span class="text-[10px] font-mono opacity-50">${typeLabel}</span>
4649
4993
  </div>
4650
4994
  </th>`;
4651
4995
  });
4652
4996
  html += "</tr></thead><tbody>";
4653
4997
  data.forEach((row) => {
4654
- html += '<tr class="hover:bg-muted/50 transition-colors">';
4998
+ html += '<tr class="hover:bg-hover transition-colors">';
4655
4999
  columns.forEach((col2) => {
4656
- html += `<td class="border-b border-r border-border px-4 py-2 max-w-[300px] overflow-hidden text-ellipsis">${formatValue(row[col2])}</td>`;
5000
+ html += `<td class="px-4 py-1.5 max-w-[300px] overflow-hidden text-ellipsis font-mono border-b border-r border-border bg-bg">${formatValue(row[col2])}</td>`;
4657
5001
  });
4658
5002
  html += "</tr>";
4659
5003
  });
4660
5004
  html += "</tbody></table></div>";
4661
- const startRecord = (page - 1) * limit + 1;
4662
- const endRecord = Math.min(page * limit, total);
4663
- html += `
4664
- <div class="flex items-center justify-between px-4 py-3 bg-card border-t border-border text-xs text-muted-foreground shrink-0 sticky bottom-0 z-10 w-full">
4665
- <div class="flex items-center gap-4">
4666
- <span>${total} records</span>
4667
- <span>${startRecord}-${endRecord}</span>
4668
- </div>
4669
- <div class="flex items-center gap-2">
4670
- <select
4671
- name="limit"
4672
- class="bg-background border border-border text-foreground px-2 py-1.5 rounded-md focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
4673
- @change="pageSize = $event.target.value; htmx.ajax('GET', '/htmx/tables/${tableName}?page=1&limit=' + pageSize, {target: '#data-container'})"
4674
- >
4675
- <option value="10" ${limit === 10 ? "selected" : ""}>10</option>
4676
- <option value="25" ${limit === 25 ? "selected" : ""}>25</option>
4677
- <option value="50" ${limit === 50 ? "selected" : ""}>50</option>
4678
- <option value="100" ${limit === 100 ? "selected" : ""}>100</option>
4679
- </select>
4680
-
4681
- <button
4682
- ${page <= 1 ? "disabled" : ""}
4683
- hx-get="/htmx/tables/${tableName}?page=${page - 1}&limit=${limit}"
4684
- hx-target="#data-container"
4685
- class="bg-background border border-border text-foreground px-3 py-1.5 rounded-md hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
4686
- >&larr;</button>
4687
-
4688
- <span class="min-w-[40px] text-center">${page} / ${totalPages}</span>
4689
-
4690
- <button
4691
- ${page >= totalPages ? "disabled" : ""}
4692
- hx-get="/htmx/tables/${tableName}?page=${page + 1}&limit=${limit}"
4693
- hx-target="#data-container"
4694
- class="bg-background border border-border text-foreground px-3 py-1.5 rounded-md hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
4695
- >&rarr;</button>
4696
- </div>
4697
- </div>
4698
- `;
4699
- html += "</div>";
5005
+ html += `<div class="px-4 py-2 text-[10px] text-muted border-t border-border font-mono shrink-0 bg-panel sticky bottom-0">${data.length} row(s) returned in ${duration2}ms</div>`;
4700
5006
  return new Response(html, { headers: { "Content-Type": "text/html" } });
4701
5007
  } catch (e) {
4702
5008
  return new Response(`
4703
5009
  <div class="p-6">
4704
5010
  <div class="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg">
4705
- <h3 class="font-semibold mb-1">Error Loading Data</h3>
4706
- <p class="text-sm opacity-80">${e.message}</p>
5011
+ <h3 class="font-semibold mb-1">Query Error</h3>
5012
+ <p class="text-sm opacity-80 font-mono break-words whitespace-pre-wrap">${e.message}</p>
4707
5013
  </div>
4708
5014
  </div>
4709
5015
  `, { headers: { "Content-Type": "text/html" } });
4710
5016
  }
4711
5017
  }
4712
5018
  if (req.method === "GET" && url.pathname === "/") {
4713
- return new Response(indexHtml, {
5019
+ const currentSchema = url.searchParams.get("schema") || config2.dbSchema || "public";
5020
+ return new Response(renderIndexHtml({ schemas: allSchemas, currentSchema }), {
4714
5021
  headers: { "Content-Type": "text/html" }
4715
5022
  });
4716
5023
  }
@@ -4725,7 +5032,7 @@ async function runStudio(config2) {
4725
5032
  // src/commands/tusky.ts
4726
5033
  var import_picocolors11 = __toESM(require_picocolors(), 1);
4727
5034
  async function runTusky(config) {
4728
- const schemas = await loadSchemas(config.schema);
5035
+ const schemas = (await loadSchemas(config.schema)).filter((s) => s.type === "table");
4729
5036
  const schemaObj = {};
4730
5037
  for (const s of schemas) {
4731
5038
  schemaObj[s.exportName] = s.table;
@@ -4855,7 +5162,7 @@ async function ensureDatabase2(dbUrl) {
4855
5162
  // package.json
4856
5163
  var package_default = {
4857
5164
  name: "@bungres/kit",
4858
- version: "1.0.0",
5165
+ version: "1.1.0",
4859
5166
  description: "CLI toolkit for @bungres/orm \u2014 migrate, push, generate, pull",
4860
5167
  license: "MIT",
4861
5168
  engines: {
@@ -4870,9 +5177,10 @@ var package_default = {
4870
5177
  types: "./dist/index.d.ts",
4871
5178
  exports: {
4872
5179
  ".": {
4873
- bun: "./src/index.ts",
5180
+ types: "./dist/index.d.ts",
4874
5181
  import: "./dist/index.js",
4875
- types: "./dist/index.d.ts"
5182
+ require: "./dist/index.js",
5183
+ default: "./dist/index.js"
4876
5184
  }
4877
5185
  },
4878
5186
  files: [