@carllee1983/dbcli 1.51.2 → 1.52.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.mjs CHANGED
@@ -52,7 +52,7 @@ var package_default;
52
52
  var init_package = __esm(() => {
53
53
  package_default = {
54
54
  name: "@carllee1983/dbcli",
55
- version: "1.51.2",
55
+ version: "1.52.0",
56
56
  description: "Database CLI for AI agents",
57
57
  type: "module",
58
58
  publishConfig: {
@@ -124,6 +124,7 @@ var init_package = __esm(() => {
124
124
  test: "bun test --timeout 30000",
125
125
  "test:unit": "bun test tests/unit tests/core",
126
126
  "test:integration": "bun test tests/integration",
127
+ "test:gherkin": "bun test tests/gherkin",
127
128
  "test:docker": "docker compose -f docker-compose.test.yml up -d --wait && bun test tests/integration/adapters; docker compose -f docker-compose.test.yml down",
128
129
  "docs:check": "bun run scripts/check-user-docs.ts",
129
130
  "contract:check": "bun run scripts/check-cli-contract.ts",
@@ -154,6 +155,8 @@ var init_package = __esm(() => {
154
155
  zod: "^3.25.76"
155
156
  },
156
157
  devDependencies: {
158
+ "@cucumber/gherkin": "^42.0.1",
159
+ "@cucumber/messages": "^34.2.1",
157
160
  "@eslint/js": "^9.39.4",
158
161
  "@happy-dom/global-registrator": "^20.10.6",
159
162
  "@inquirer/prompts": "^8.4.3",
@@ -80459,6 +80462,9 @@ class QueryResultFormatter {
80459
80462
  if (result.executionTimeMs !== undefined) {
80460
80463
  footerLines.push(`Execution time: ${result.executionTimeMs}ms`);
80461
80464
  }
80465
+ if (result.metadata?.performanceAdvisory) {
80466
+ footerLines.push(`Performance hint: ${result.metadata.performanceAdvisory.recommendation}`);
80467
+ }
80462
80468
  return footerLines.join(" | ");
80463
80469
  }
80464
80470
  formatCSV(result) {
@@ -86348,6 +86354,63 @@ var init_query_fanout = __esm(() => {
86348
86354
  init_permission_guard();
86349
86355
  });
86350
86356
 
86357
+ // src/core/slow-query-advisory.ts
86358
+ function slowQueryThresholdFor(options) {
86359
+ if (options.recovery === true)
86360
+ return 0;
86361
+ const threshold = options.slowMs ?? DEFAULT_SLOW_QUERY_MS;
86362
+ assertValidSlowQueryThreshold(threshold);
86363
+ return threshold;
86364
+ }
86365
+ function assertValidSlowQueryThreshold(value) {
86366
+ if (value === undefined)
86367
+ return;
86368
+ if (!Number.isSafeInteger(value) || value < 0) {
86369
+ throw new Error("slow-ms must be a non-negative integer");
86370
+ }
86371
+ }
86372
+ function parseSlowQueryThreshold(value) {
86373
+ if (!/^\d+$/.test(value)) {
86374
+ throw new Error("slow-ms must be a non-negative integer");
86375
+ }
86376
+ const parsed = Number(value);
86377
+ assertValidSlowQueryThreshold(parsed);
86378
+ return parsed;
86379
+ }
86380
+ function recommendationFor(system) {
86381
+ const covered = system === undefined || GUIDE_SLOW_QUERY_SYSTEMS.includes(system);
86382
+ return covered ? `Review safely with: dbcli guide slow-query --format markdown. ${NO_EXTRA_WORK}` : `Re-check the query shape and the filters it scans; dbcli ships no slow-query diagnostics for ${system}. ${NO_EXTRA_WORK}`;
86383
+ }
86384
+ function buildSlowQueryAdvisory(executionTimeMs, options) {
86385
+ const thresholdMs = slowQueryThresholdFor(options);
86386
+ if (thresholdMs === 0 || executionTimeMs === undefined || executionTimeMs < thresholdMs) {
86387
+ return;
86388
+ }
86389
+ return {
86390
+ code: "SLOW_QUERY",
86391
+ executionTimeMs,
86392
+ thresholdMs,
86393
+ recommendation: recommendationFor(options.system)
86394
+ };
86395
+ }
86396
+ function attachSlowQueryAdvisory(result, options) {
86397
+ const advisory = buildSlowQueryAdvisory(result.executionTimeMs, options);
86398
+ if (!advisory)
86399
+ return result;
86400
+ return {
86401
+ ...result,
86402
+ metadata: {
86403
+ statement: result.metadata?.statement ?? "UNKNOWN",
86404
+ ...result.metadata,
86405
+ performanceAdvisory: advisory
86406
+ }
86407
+ };
86408
+ }
86409
+ var DEFAULT_SLOW_QUERY_MS = 1000, GUIDE_SLOW_QUERY_SYSTEMS, NO_EXTRA_WORK = "This hint runs no additional database diagnostics.";
86410
+ var init_slow_query_advisory = __esm(() => {
86411
+ GUIDE_SLOW_QUERY_SYSTEMS = ["mariadb", "mysql", "postgresql", "redis"];
86412
+ });
86413
+
86351
86414
  // src/commands/query-size-guard.ts
86352
86415
  var exports_query_size_guard = {};
86353
86416
  __export(exports_query_size_guard, {
@@ -86409,6 +86472,7 @@ async function queryCommand(sql, options, command) {
86409
86472
  if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit <= 0)) {
86410
86473
  throw new Error("--limit must be a positive integer");
86411
86474
  }
86475
+ assertValidSlowQueryThreshold(options.slowMs);
86412
86476
  if (options.format) {
86413
86477
  validateFormat(options.format, [...ALLOWED_FORMATS3, "html"], "query");
86414
86478
  }
@@ -86628,6 +86692,14 @@ async function executeConnectionQuery(query, options, context, fieldSelection) {
86628
86692
  } else {
86629
86693
  execution = await sqlQueryBranch(query, options, context, fieldSelection);
86630
86694
  }
86695
+ execution = {
86696
+ ...execution,
86697
+ result: attachSlowQueryAdvisory(execution.result, {
86698
+ slowMs: options.slowMs,
86699
+ recovery: options.recovery,
86700
+ system
86701
+ })
86702
+ };
86631
86703
  await writeAuditEntry(config, "query", auditOptions, {
86632
86704
  success: true,
86633
86705
  sql: query,
@@ -86936,6 +87008,7 @@ var init_query = __esm(() => {
86936
87008
  init_connection_selector();
86937
87009
  init_query_fanout();
86938
87010
  init_permission_guard();
87011
+ init_slow_query_advisory();
86939
87012
  ALLOWED_FORMATS3 = ["table", "json", "csv"];
86940
87013
  });
86941
87014
 
@@ -87527,14 +87600,14 @@ async function qMongoBranch(snippet, prepared, options, config) {
87527
87600
  } else {
87528
87601
  const columnNames = masked[0] ? Object.keys(masked[0]) : [];
87529
87602
  const formatter = new QueryResultFormatter;
87530
- console.log(formatter.format({
87603
+ console.log(formatter.format(attachSlowQueryAdvisory({
87531
87604
  rows: masked,
87532
87605
  rowCount: masked.length,
87533
87606
  columnNames,
87534
87607
  columnTypes: [],
87535
87608
  executionTimeMs,
87536
87609
  metadata: { statement: "SELECT", affectedRows: 0 }
87537
- }, { format: options.format ?? "table" }));
87610
+ }, { slowMs: options.slowMs, recovery: options.recovery, system: "mongodb" }), { format: options.format ?? "table" }));
87538
87611
  }
87539
87612
  await writeAuditEntry(config, "q", options, {
87540
87613
  success: true,
@@ -87555,6 +87628,7 @@ var init_q_mongo = __esm(() => {
87555
87628
  init_html_formatter();
87556
87629
  init_opener();
87557
87630
  init_integration_helper();
87631
+ init_slow_query_advisory();
87558
87632
  });
87559
87633
 
87560
87634
  // src/commands/q.ts
@@ -87591,6 +87665,7 @@ async function qCommand(name2, options, command) {
87591
87665
  let config;
87592
87666
  let targetNameForAudit = name2;
87593
87667
  try {
87668
+ assertValidSlowQueryThreshold(options.slowMs);
87594
87669
  if (!name2?.startsWith("@")) {
87595
87670
  throw new Error(`Snippet name must start with '@' (got '${name2}')`);
87596
87671
  }
@@ -87686,7 +87761,7 @@ async function qCommand(name2, options, command) {
87686
87761
  return;
87687
87762
  }
87688
87763
  const formatter = new QueryResultFormatter;
87689
- const out = formatter.format({
87764
+ const out = formatter.format(attachSlowQueryAdvisory({
87690
87765
  rows: filtered.filteredRows,
87691
87766
  rowCount: filtered.filteredRows.length,
87692
87767
  columnNames: columnNames.filter((c2) => !filtered.omittedColumns.includes(c2)),
@@ -87698,7 +87773,7 @@ async function qCommand(name2, options, command) {
87698
87773
  ...securityNotification ? { securityNotification } : {}
87699
87774
  },
87700
87775
  ...limitedResult ? { appliedLimit: limitedResult.metadata } : {}
87701
- }, { format: options.format ?? "table" });
87776
+ }, { slowMs: options.slowMs, recovery: options.recovery, system: connectionSystem }), { format: options.format ?? "table" });
87702
87777
  console.log(out);
87703
87778
  await writeAuditEntry(config, "q", options, {
87704
87779
  success: true,
@@ -87915,6 +87990,7 @@ var init_q = __esm(() => {
87915
87990
  init_applied_limit();
87916
87991
  init_cli_error();
87917
87992
  init_strategies();
87993
+ init_slow_query_advisory();
87918
87994
  });
87919
87995
 
87920
87996
  // src/core/saved-queries/fold.ts
@@ -93917,7 +93993,7 @@ var init_normalized_schema = __esm(() => {
93917
93993
  }))
93918
93994
  });
93919
93995
  normalizedSchemaZod = exports_external.object({
93920
- source: exports_external.enum(["db", "prisma", "ddl", "json", "drizzle", "typeorm", "sequelize"]),
93996
+ source: exports_external.enum(["db", "design", "prisma", "ddl", "json", "drizzle", "typeorm", "sequelize"]),
93921
93997
  defaultSchema: exports_external.string().min(1).optional(),
93922
93998
  tables: exports_external.array(tableZod),
93923
93999
  unparsed: exports_external.array(exports_external.object({ location: exports_external.string(), reason: exports_external.string().startsWith("blocked:") }))
@@ -94059,6 +94135,7 @@ function compareNormalized(orm, db, opts) {
94059
94135
  const extraDefaultIgnore = opts.extraDefaultIgnore ?? [];
94060
94136
  const defaultIgnore = [...DEFAULT_IGNORE, ...extraDefaultIgnore];
94061
94137
  const ignorePatterns = opts.ignore.map(globToRegex2);
94138
+ const targetLabel = opts.targetLabel ?? "database";
94062
94139
  const entries = [];
94063
94140
  for (const tableKey of tableKeys) {
94064
94141
  const ormTable = ormTables.get(tableKey);
@@ -94084,7 +94161,7 @@ function compareNormalized(orm, db, opts) {
94084
94161
  severity: "error",
94085
94162
  table,
94086
94163
  object: "table",
94087
- detail: `table '${table}' is defined in ${orm.source} but absent in the database`
94164
+ detail: `table '${table}' is defined in ${orm.source} but absent in the ${targetLabel}`
94088
94165
  }));
94089
94166
  continue;
94090
94167
  }
@@ -94094,12 +94171,12 @@ function compareNormalized(orm, db, opts) {
94094
94171
  severity: "warn",
94095
94172
  table,
94096
94173
  object: "table",
94097
- detail: `table '${table}' exists in the database but is not defined in ${orm.source}`
94174
+ detail: `table '${table}' exists in the ${targetLabel} but is not defined in ${orm.source}`
94098
94175
  }));
94099
94176
  continue;
94100
94177
  }
94101
94178
  if (ormTable && dbTable)
94102
- compareTable(ormTable, dbTable, table, orm.source, entries);
94179
+ compareTable(ormTable, dbTable, table, orm.source, targetLabel, entries);
94103
94180
  }
94104
94181
  entries.sort(entryOrder);
94105
94182
  const summary = {
@@ -94115,7 +94192,7 @@ function compareNormalized(orm, db, opts) {
94115
94192
  summary
94116
94193
  };
94117
94194
  }
94118
- function compareTable(ormTable, dbTable, table, ormSource, entries) {
94195
+ function compareTable(ormTable, dbTable, table, ormSource, targetLabel, entries) {
94119
94196
  const ormColumns = new Map(ormTable.columns.map((column) => [column.name.toLowerCase(), column]));
94120
94197
  const dbColumns = new Map(dbTable.columns.map((column) => [column.name.toLowerCase(), column]));
94121
94198
  for (const [columnKey, ormColumn] of ormColumns) {
@@ -94126,7 +94203,7 @@ function compareTable(ormTable, dbTable, table, ormSource, entries) {
94126
94203
  severity: "error",
94127
94204
  table,
94128
94205
  object: ormColumn.name,
94129
- detail: `column '${ormColumn.name}' (${ormColumn.type}) is defined in ${ormSource} but absent in the database`
94206
+ detail: `column '${ormColumn.name}' (${ormColumn.type}) is defined in ${ormSource} but absent in the ${targetLabel}`
94130
94207
  }, { kind: "column", table: ormTable.identity, column: ormColumn }));
94131
94208
  continue;
94132
94209
  }
@@ -94149,10 +94226,44 @@ function compareTable(ormTable, dbTable, table, ormSource, entries) {
94149
94226
  severity: "warn",
94150
94227
  table,
94151
94228
  object: dbColumn.name,
94152
- detail: `column '${dbColumn.name}' (${dbColumn.type}) exists in the database but is not defined in ${ormSource}`
94229
+ detail: `column '${dbColumn.name}' (${dbColumn.type}) exists in the ${targetLabel} but is not defined in ${ormSource}`
94153
94230
  }));
94154
94231
  }
94155
- compareIndexes(ormTable.indexes, dbTable.indexes, ormTable.identity, table, ormSource, entries);
94232
+ compareIndexes(ormTable.indexes, dbTable.indexes, ormTable.identity, table, ormSource, targetLabel, entries);
94233
+ compareForeignKeys(ormTable.foreignKeys, dbTable.foreignKeys, table, ormSource, targetLabel, entries);
94234
+ }
94235
+ function compareForeignKeys(ormForeignKeys, dbForeignKeys, table, ormSource, targetLabel, entries) {
94236
+ const ormByKey = new Map(ormForeignKeys.map((foreignKey) => [foreignKeyKey(foreignKey), foreignKey]));
94237
+ const dbByKey = new Map(dbForeignKeys.map((foreignKey) => [foreignKeyKey(foreignKey), foreignKey]));
94238
+ for (const [key, foreignKey] of ormByKey) {
94239
+ if (dbByKey.has(key))
94240
+ continue;
94241
+ entries.push(entryWithProposals({
94242
+ category: "missing_in_db",
94243
+ severity: "error",
94244
+ table,
94245
+ object: `foreign key (${foreignKey.columns.join(", ")})`,
94246
+ detail: `foreign key (${foreignKey.columns.join(", ")}) \u2192 ${qualifiedTableName(foreignKey.refTable)}(${foreignKey.refColumns.join(", ")}) is defined in ${ormSource} but absent in the ${targetLabel}`
94247
+ }));
94248
+ }
94249
+ for (const [key, foreignKey] of dbByKey) {
94250
+ if (ormByKey.has(key))
94251
+ continue;
94252
+ entries.push(entryWithProposals({
94253
+ category: "missing_in_orm",
94254
+ severity: "warn",
94255
+ table,
94256
+ object: `foreign key (${foreignKey.columns.join(", ")})`,
94257
+ detail: `foreign key (${foreignKey.columns.join(", ")}) \u2192 ${qualifiedTableName(foreignKey.refTable)}(${foreignKey.refColumns.join(", ")}) exists in the ${targetLabel} but is not defined in ${ormSource}`
94258
+ }));
94259
+ }
94260
+ }
94261
+ function foreignKeyKey(foreignKey) {
94262
+ return [
94263
+ foreignKey.columns.join("\x00"),
94264
+ tableIdentityKey(foreignKey.refTable),
94265
+ foreignKey.refColumns.join("\x00")
94266
+ ].join("\x01");
94156
94267
  }
94157
94268
  function columnMismatch(ormColumn, dbColumn) {
94158
94269
  const reasons = [];
@@ -94180,7 +94291,7 @@ function columnMismatch(ormColumn, dbColumn) {
94180
94291
  function displayOptional(value) {
94181
94292
  return value === undefined ? "none" : value;
94182
94293
  }
94183
- function compareIndexes(ormIndexes, dbIndexes, tableIdentity, table, ormSource, entries) {
94294
+ function compareIndexes(ormIndexes, dbIndexes, tableIdentity, table, ormSource, targetLabel, entries) {
94184
94295
  const indexKey = (index) => JSON.stringify([index.columns.map((column) => column.toLowerCase()), index.unique]);
94185
94296
  const dbKeys = new Set(dbIndexes.map(indexKey));
94186
94297
  const ormKeys = new Set(ormIndexes.map(indexKey));
@@ -94196,7 +94307,7 @@ function compareIndexes(ormIndexes, dbIndexes, tableIdentity, table, ormSource,
94196
94307
  severity: "error",
94197
94308
  table,
94198
94309
  object: `index(${index.columns.join(",")})`,
94199
- detail: `${index.unique ? "unique " : ""}index on (${index.columns.join(", ")}) is defined in ${ormSource} but absent in the database`
94310
+ detail: `${index.unique ? "unique " : ""}index on (${index.columns.join(", ")}) is defined in ${ormSource} but absent in the ${targetLabel}`
94200
94311
  }, { kind: "index", table: tableIdentity, index }));
94201
94312
  }
94202
94313
  for (const index of dbIndexes) {
@@ -94209,7 +94320,7 @@ function compareIndexes(ormIndexes, dbIndexes, tableIdentity, table, ormSource,
94209
94320
  severity: "warn",
94210
94321
  table,
94211
94322
  object: `index(${index.columns.join(",")})`,
94212
- detail: `${index.unique ? "unique " : ""}index on (${index.columns.join(", ")}) exists in the database but is not defined in ${ormSource}`
94323
+ detail: `${index.unique ? "unique " : ""}index on (${index.columns.join(", ")}) exists in the ${targetLabel} but is not defined in ${ormSource}`
94213
94324
  }, { kind: "index", table: tableIdentity, index }));
94214
94325
  }
94215
94326
  }
@@ -94417,15 +94528,7 @@ function mergeNormalizedSchemas(schemas) {
94417
94528
  unparsed: schemas.flatMap((schema) => schema.unparsed)
94418
94529
  });
94419
94530
  }
94420
- async function runDrift(paths, options, config) {
94421
- const system = config.connection?.system;
94422
- if (!system || !["postgresql", "mysql", "mariadb"].includes(system)) {
94423
- throw new Error(`This command requires a SQL connection, got: ${system ?? "none"}`);
94424
- }
94425
- const cached2 = config.schema ?? {};
94426
- if (Object.keys(cached2).length === 0) {
94427
- throw new Error("Schema cache is empty. Run 'dbcli schema' first.");
94428
- }
94531
+ async function loadOrmSchema(paths, options) {
94429
94532
  const ormFormat = parseOrmFormat(options.ormFormat);
94430
94533
  const includesGlob = parseAgainstOrmValues(paths).some(hasGlobMagic);
94431
94534
  const expandedPaths = await expandOrmPaths(paths);
@@ -94456,25 +94559,41 @@ async function runDrift(paths, options, config) {
94456
94559
  if (includesGlob && inputs.some((input) => !isDdlFormat(input.format))) {
94457
94560
  throw new Error("Glob ORM schema inputs are supported only for DDL");
94458
94561
  }
94459
- const merged = inputs.every((input) => isDdlFormat(input.format)) ? parseDdlFiles(inputs.map((input) => input.content), system) : mergeNormalizedSchemas(inputs.map(({ content, format }) => {
94562
+ const merged = inputs.every((input) => isDdlFormat(input.format)) ? parseDdlFiles(inputs.map((input) => input.content), options.system) : mergeNormalizedSchemas(inputs.map(({ content, format }) => {
94460
94563
  if (format === "prisma")
94461
94564
  return parsePrismaSchema(content);
94462
- if (format === "ddl" || format in ORM_ALIASES) {
94463
- return parseDdl(content, system);
94464
- }
94565
+ if (format === "ddl" || format in ORM_ALIASES)
94566
+ return parseDdl(content, options.system);
94465
94567
  if (format === "drizzle")
94466
94568
  return parseDrizzleSnapshot(JSON.parse(content));
94467
94569
  const parsed = normalizedSchemaZod.parse(JSON.parse(content));
94468
94570
  return { ...parsed, source: "json" };
94469
94571
  }));
94470
94572
  const alias = ormFormat && ormFormat in ORM_ALIASES ? ormFormat : undefined;
94471
- const orm = alias ? { ...merged, source: alias } : merged;
94573
+ return {
94574
+ schema: alias ? { ...merged, source: alias } : merged,
94575
+ ...alias ? { extraDefaultIgnore: [...ORM_ALIASES[alias].defaultIgnore] } : {}
94576
+ };
94577
+ }
94578
+ async function runDrift(paths, options, config) {
94579
+ const system = config.connection?.system;
94580
+ if (!system || !["postgresql", "mysql", "mariadb"].includes(system)) {
94581
+ throw new Error(`This command requires a SQL connection, got: ${system ?? "none"}`);
94582
+ }
94583
+ const cached2 = config.schema ?? {};
94584
+ if (Object.keys(cached2).length === 0) {
94585
+ throw new Error("Schema cache is empty. Run 'dbcli schema' first.");
94586
+ }
94587
+ const { schema: orm, extraDefaultIgnore } = await loadOrmSchema(paths, {
94588
+ ...options.ormFormat !== undefined && { ormFormat: options.ormFormat },
94589
+ system
94590
+ });
94472
94591
  const ignore = (options.ignore ?? "").split(",").map((pattern) => pattern.trim()).filter(Boolean);
94473
94592
  const db = normalizeDbSchema(cached2, system === "postgresql" ? { defaultSchema: "public" } : undefined);
94474
94593
  return {
94475
94594
  report: compareNormalized(orm, db, {
94476
94595
  ignore,
94477
- extraDefaultIgnore: alias ? [...ORM_ALIASES[alias].defaultIgnore] : undefined
94596
+ extraDefaultIgnore
94478
94597
  })
94479
94598
  };
94480
94599
  }
@@ -108079,6 +108198,632 @@ var init_semantic2 = __esm(() => {
108079
108198
  });
108080
108199
  });
108081
108200
 
108201
+ // src/core/design/index.ts
108202
+ import { join as join40 } from "path";
108203
+ function defaultDesignFile(workspaceRoot) {
108204
+ return join40(workspaceRoot, DEFAULT_FILE2);
108205
+ }
108206
+ function parseDesignSpec(raw, filePath = DEFAULT_FILE2) {
108207
+ const parsed = specSchema.safeParse(raw);
108208
+ if (!parsed.success) {
108209
+ throw new DesignValidationError(filePath, parsed.error.issues.map((issue2) => ({
108210
+ path: formatZodPath(issue2.path),
108211
+ message: issue2.message
108212
+ })));
108213
+ }
108214
+ return normalizeSpec(parsed.data);
108215
+ }
108216
+ async function loadDesignSpec(filePath) {
108217
+ const file = Bun.file(filePath);
108218
+ if (!await file.exists()) {
108219
+ throw new DesignValidationError(filePath, [{ path: "$", message: "file not found" }]);
108220
+ }
108221
+ if (file.size > MAX_FILE_BYTES2) {
108222
+ throw new DesignValidationError(filePath, [
108223
+ { path: "$", message: `must not exceed ${MAX_FILE_BYTES2} bytes` }
108224
+ ]);
108225
+ }
108226
+ try {
108227
+ return parseDesignSpec(JSON.parse(await file.text()), filePath);
108228
+ } catch (error) {
108229
+ if (error instanceof DesignValidationError)
108230
+ throw error;
108231
+ throw new DesignValidationError(filePath, [{ path: "$", message: "must be valid JSON" }]);
108232
+ }
108233
+ }
108234
+ function reviewDesign(spec) {
108235
+ const findings = [];
108236
+ const models = new Map;
108237
+ const tables = new Map;
108238
+ if (spec.models.length === 0)
108239
+ addFinding(findings, "error", "NO_MODELS", "$.models", "must contain at least one model");
108240
+ for (const [modelIndex, model] of spec.models.entries()) {
108241
+ const path6 = `$.models[${modelIndex}]`;
108242
+ if (models.has(model.name))
108243
+ addFinding(findings, "error", "DUPLICATE_MODEL", `${path6}.name`, "must be unique");
108244
+ else
108245
+ models.set(model.name, { model, index: modelIndex });
108246
+ if (tables.has(model.table))
108247
+ addFinding(findings, "error", "DUPLICATE_TABLE", `${path6}.table`, "must be unique");
108248
+ else
108249
+ tables.set(model.table, modelIndex);
108250
+ reviewModel(model, modelIndex, findings);
108251
+ }
108252
+ const relationships = new Set;
108253
+ for (const [relationshipIndex, relationship] of spec.relationships.entries()) {
108254
+ const path6 = `$.relationships[${relationshipIndex}]`;
108255
+ const key = `${relationship.from.model}\x00${relationship.from.field}\x00${relationship.to.model}\x00${relationship.to.field}`;
108256
+ const reverseKey = `${relationship.to.model}\x00${relationship.to.field}\x00${relationship.from.model}\x00${relationship.from.field}`;
108257
+ if (relationships.has(key))
108258
+ addFinding(findings, "error", "DUPLICATE_RELATIONSHIP", path6, "must not repeat relationship endpoints");
108259
+ else if (key !== reverseKey && relationships.has(reverseKey)) {
108260
+ addFinding(findings, "error", "REVERSE_RELATIONSHIP", path6, "must use one relationship direction; the reverse endpoints are already declared");
108261
+ }
108262
+ relationships.add(key);
108263
+ reviewRelationship(relationship, path6, models, findings);
108264
+ }
108265
+ for (const [patternIndex, pattern] of spec.accessPatterns.entries()) {
108266
+ reviewAccessPattern(pattern, `$.accessPatterns[${patternIndex}]`, models, findings);
108267
+ }
108268
+ findings.sort(findingOrder);
108269
+ return {
108270
+ findings,
108271
+ summary: {
108272
+ errors: findings.filter((finding) => finding.severity === "error").length,
108273
+ warns: findings.filter((finding) => finding.severity === "warn").length,
108274
+ infos: findings.filter((finding) => finding.severity === "info").length
108275
+ }
108276
+ };
108277
+ }
108278
+ function compileDesignSchema(spec) {
108279
+ const models = new Map(spec.models.map((model) => [model.name, model]));
108280
+ return {
108281
+ source: "design",
108282
+ ...spec.dialect === "postgresql" ? { defaultSchema: "public" } : {},
108283
+ tables: spec.models.map((model) => ({
108284
+ identity: { table: model.table },
108285
+ columns: model.fields.map((field) => ({
108286
+ name: field.name,
108287
+ type: field.type,
108288
+ nullable: field.nullable,
108289
+ primaryKey: field.primaryKey || undefined
108290
+ })),
108291
+ indexes: [
108292
+ ...model.indexes.map((index) => ({ ...index })),
108293
+ ...model.fields.filter((field) => field.unique && !field.primaryKey).map((field) => ({ columns: [field.name], unique: true }))
108294
+ ],
108295
+ foreignKeys: spec.relationships.flatMap((relationship) => {
108296
+ if (relationship.cardinality === "many-to-many" || relationship.from.model !== model.name)
108297
+ return [];
108298
+ const target = models.get(relationship.to.model);
108299
+ if (!target)
108300
+ return [];
108301
+ return [
108302
+ {
108303
+ columns: [relationship.from.field],
108304
+ refTable: { table: target.table },
108305
+ refColumns: [relationship.to.field]
108306
+ }
108307
+ ];
108308
+ })
108309
+ })),
108310
+ unparsed: []
108311
+ };
108312
+ }
108313
+ function planDesignProposals(report) {
108314
+ return {
108315
+ report,
108316
+ proposals: report.entries.filter((entry) => entry.category !== "unmanaged").map((entry) => ({
108317
+ table: entry.table,
108318
+ object: entry.object,
108319
+ safety: entry.proposedCommands.some((command) => command.startsWith("# dry-run")) ? "dry-run" : "migration-review",
108320
+ commands: entry.proposedCommands,
108321
+ preflight: [
108322
+ "dbcli blacklist list",
108323
+ "Confirm the exact affected table with: dbcli schema <exact-table> --format json"
108324
+ ],
108325
+ rollback: "Capture the current schema and generated DDL before any approved write; define the inverse migration before execution.",
108326
+ verification: [
108327
+ "After an approved write, run: dbcli schema <exact-table> --format json",
108328
+ "Re-run this same design diff command and review the remaining drift."
108329
+ ]
108330
+ }))
108331
+ };
108332
+ }
108333
+ function reviewModel(model, modelIndex, findings) {
108334
+ const fields = new Map;
108335
+ const primaryKeys = model.fields.filter((field) => field.primaryKey);
108336
+ if (primaryKeys.length !== 1)
108337
+ addFinding(findings, "error", "PRIMARY_KEY_COUNT", `$.models[${modelIndex}].fields`, "must declare exactly one primary-key field in v1");
108338
+ for (const [fieldIndex, field] of model.fields.entries()) {
108339
+ const path6 = `$.models[${modelIndex}].fields[${fieldIndex}]`;
108340
+ if (fields.has(field.name))
108341
+ addFinding(findings, "error", "DUPLICATE_FIELD", `${path6}.name`, "must be unique within its model");
108342
+ else
108343
+ fields.set(field.name, fieldIndex);
108344
+ if (field.primaryKey && field.nullable)
108345
+ addFinding(findings, "error", "NULLABLE_PRIMARY_KEY", `${path6}.nullable`, "primary-key fields must not be nullable");
108346
+ }
108347
+ const indexKeys = new Set;
108348
+ for (const [indexIndex, index] of model.indexes.entries()) {
108349
+ const path6 = `$.models[${modelIndex}].indexes[${indexIndex}]`;
108350
+ const key = `${index.unique}\x00${index.columns.join("\x00")}`;
108351
+ if (indexKeys.has(key))
108352
+ addFinding(findings, "warn", "DUPLICATE_INDEX", path6, "duplicates a prior index");
108353
+ indexKeys.add(key);
108354
+ for (const column of index.columns) {
108355
+ if (!fields.has(column))
108356
+ addFinding(findings, "error", "UNKNOWN_INDEX_FIELD", `${path6}.columns`, `references unknown field '${column}'`);
108357
+ }
108358
+ if (index.columns.length === 1 && primaryKeys.some((field) => field.name === index.columns[0])) {
108359
+ addFinding(findings, "warn", "REDUNDANT_PRIMARY_KEY_INDEX", path6, "primary-key fields are already indexed");
108360
+ }
108361
+ if (!index.unique && model.indexes.some((candidate) => candidate.columns.length > index.columns.length && index.columns.every((column, columnIndex) => candidate.columns[columnIndex] === column))) {
108362
+ addFinding(findings, "warn", "PREFIX_REDUNDANT_INDEX", path6, "is covered by a longer index with the same leading columns");
108363
+ }
108364
+ }
108365
+ }
108366
+ function reviewRelationship(relationship, path6, models, findings) {
108367
+ const from = lookupField(relationship.from, `${path6}.from`, models, findings);
108368
+ const to = lookupField(relationship.to, `${path6}.to`, models, findings);
108369
+ if (!from || !to)
108370
+ return;
108371
+ if (relationship.cardinality === "many-to-many") {
108372
+ addFinding(findings, "error", "MANY_TO_MANY_REQUIRES_BRIDGE", `${path6}.cardinality`, "requires an explicit bridge model in v1");
108373
+ }
108374
+ if (typeFamily(from.field.type) !== typeFamily(to.field.type)) {
108375
+ addFinding(findings, "error", "RELATIONSHIP_TYPE_MISMATCH", path6, `field types are incompatible (${from.field.type} vs ${to.field.type})`);
108376
+ }
108377
+ if (relationship.cardinality === "one-to-one" && !isUniqueField(from.model, from.field.name)) {
108378
+ addFinding(findings, "error", "ONE_TO_ONE_REQUIRES_UNIQUE_FK", `${path6}.from.field`, "must be primary-key, unique, or covered by a single-column unique index");
108379
+ }
108380
+ }
108381
+ function reviewAccessPattern(pattern, path6, models, findings) {
108382
+ const found = models.get(pattern.model);
108383
+ if (!found) {
108384
+ addFinding(findings, "error", "UNKNOWN_ACCESS_MODEL", `${path6}.model`, `references unknown model '${pattern.model}'`);
108385
+ return;
108386
+ }
108387
+ const fields = new Set(found.model.fields.map((field) => field.name));
108388
+ for (const field of [...pattern.filters, ...pattern.sort]) {
108389
+ if (!fields.has(field))
108390
+ addFinding(findings, "error", "UNKNOWN_ACCESS_FIELD", path6, `references unknown field '${field}'`);
108391
+ }
108392
+ const needed = [...pattern.filters, ...pattern.sort];
108393
+ if (needed.length > 0 && !hasSupportingIndex(found.model, needed)) {
108394
+ addFinding(findings, "warn", "ACCESS_PATTERN_INDEX", path6, `consider an index beginning with (${needed.join(", ")})`);
108395
+ }
108396
+ }
108397
+ function lookupField(endpoint, path6, models, findings) {
108398
+ const found = models.get(endpoint.model);
108399
+ if (!found) {
108400
+ addFinding(findings, "error", "UNKNOWN_RELATIONSHIP_MODEL", `${path6}.model`, `references unknown model '${endpoint.model}'`);
108401
+ return;
108402
+ }
108403
+ const field = found.model.fields.find((candidate) => candidate.name === endpoint.field);
108404
+ if (!field) {
108405
+ addFinding(findings, "error", "UNKNOWN_RELATIONSHIP_FIELD", `${path6}.field`, `references unknown field '${endpoint.field}'`);
108406
+ return;
108407
+ }
108408
+ return { model: found.model, field };
108409
+ }
108410
+ function isUniqueField(model, field) {
108411
+ return model.fields.some((candidate) => candidate.name === field && (candidate.primaryKey || candidate.unique)) || model.indexes.some((index) => index.unique && index.columns.length === 1 && index.columns[0] === field);
108412
+ }
108413
+ function hasSupportingIndex(model, needed) {
108414
+ const indexes = [
108415
+ ...model.indexes.map((index) => index.columns),
108416
+ ...model.fields.filter((field) => field.primaryKey || field.unique).map((field) => [field.name])
108417
+ ];
108418
+ return indexes.some((columns) => needed.every((column, index) => columns[index] === column));
108419
+ }
108420
+ function addFinding(findings, severity, code, path6, message) {
108421
+ findings.push({ severity, code, path: path6, message });
108422
+ }
108423
+ function normalizeSpec(spec) {
108424
+ return {
108425
+ ...spec,
108426
+ models: [...spec.models].sort((left, right) => codePointOrder2(left.name, right.name)),
108427
+ relationships: [...spec.relationships].sort((left, right) => codePointOrder2(left.name, right.name)),
108428
+ accessPatterns: [...spec.accessPatterns].sort((left, right) => {
108429
+ const leftKey = `${left.model}\x00${left.filters.join("\x00")}\x00${left.sort.join("\x00")}`;
108430
+ const rightKey = `${right.model}\x00${right.filters.join("\x00")}\x00${right.sort.join("\x00")}`;
108431
+ return codePointOrder2(leftKey, rightKey);
108432
+ }),
108433
+ decisions: [...spec.decisions].sort((left, right) => codePointOrder2(left.name, right.name))
108434
+ };
108435
+ }
108436
+ function formatZodPath(path6) {
108437
+ return path6.reduce((result, part) => typeof part === "number" ? `${result}[${part}]` : `${result}.${part}`, "$");
108438
+ }
108439
+ function findingOrder(left, right) {
108440
+ return codePointOrder2(left.path, right.path) || codePointOrder2(left.code, right.code) || codePointOrder2(left.message, right.message);
108441
+ }
108442
+ function codePointOrder2(left, right) {
108443
+ const leftPoints = [...left];
108444
+ const rightPoints = [...right];
108445
+ for (let index = 0;index < Math.min(leftPoints.length, rightPoints.length); index += 1) {
108446
+ const difference = leftPoints[index].codePointAt(0) - rightPoints[index].codePointAt(0);
108447
+ if (difference !== 0)
108448
+ return difference;
108449
+ }
108450
+ return leftPoints.length - rightPoints.length;
108451
+ }
108452
+ var DesignValidationError, DEFAULT_FILE2 = "dbcli.design.json", MAX_FILE_BYTES2, MAX_MODELS2 = 100, MAX_FIELDS_PER_MODEL2 = 100, MAX_RELATIONSHIPS2 = 200, MAX_ACCESS_PATTERNS = 200, IDENTIFIER3, DESIGN_NAME, TYPE, UNSAFE_TEXT, identifier, designName, text2, fieldSchema, indexSchema, modelSchema, endpointSchema, relationshipSchema, accessPatternSchema, decisionSchema, specSchema;
108453
+ var init_design = __esm(() => {
108454
+ init_zod();
108455
+ init_normalized_schema();
108456
+ DesignValidationError = class DesignValidationError extends Error {
108457
+ filePath;
108458
+ issues;
108459
+ constructor(filePath, issues) {
108460
+ super(`Invalid design artifact at ${filePath}: ${issues.map((issue2) => `${issue2.path}: ${issue2.message}`).join("; ")}`);
108461
+ this.filePath = filePath;
108462
+ this.issues = issues;
108463
+ this.name = "DesignValidationError";
108464
+ }
108465
+ };
108466
+ MAX_FILE_BYTES2 = 256 * 1024;
108467
+ IDENTIFIER3 = /^[A-Za-z_][A-Za-z0-9_]*$/;
108468
+ DESIGN_NAME = /^[a-z][a-z0-9-]*$/;
108469
+ TYPE = /^[^;\r\n]{1,100}$/;
108470
+ UNSAFE_TEXT = /(?:\b(?:select|insert|update|delete|alter|drop|grant)\b\s+|\bcreate\s+(?:table|index)\b|(?:postgres(?:ql)?|mysql):\/\/)/i;
108471
+ identifier = exports_external.string().regex(IDENTIFIER3, "must be a SQL-safe identifier");
108472
+ designName = exports_external.string().regex(DESIGN_NAME, "must be lowercase kebab-case");
108473
+ text2 = exports_external.string().min(1).max(1000).refine((value) => !UNSAFE_TEXT.test(value), "must not contain SQL or connection data");
108474
+ fieldSchema = exports_external.object({
108475
+ name: identifier,
108476
+ type: exports_external.string().regex(TYPE, "must be a bounded type declaration without SQL separators").refine((value) => !UNSAFE_TEXT.test(value), "must not contain SQL or connection data"),
108477
+ nullable: exports_external.boolean(),
108478
+ primaryKey: exports_external.boolean().optional().default(false),
108479
+ unique: exports_external.boolean().optional().default(false),
108480
+ description: text2.optional()
108481
+ }).strict();
108482
+ indexSchema = exports_external.object({
108483
+ name: identifier.optional(),
108484
+ columns: exports_external.array(identifier).min(1).max(16),
108485
+ unique: exports_external.boolean().optional().default(false)
108486
+ }).strict();
108487
+ modelSchema = exports_external.object({
108488
+ name: designName,
108489
+ table: identifier,
108490
+ description: text2.optional(),
108491
+ fields: exports_external.array(fieldSchema).max(MAX_FIELDS_PER_MODEL2),
108492
+ indexes: exports_external.array(indexSchema).optional().default([])
108493
+ }).strict();
108494
+ endpointSchema = exports_external.object({ model: designName, field: identifier }).strict();
108495
+ relationshipSchema = exports_external.object({
108496
+ name: designName,
108497
+ from: endpointSchema,
108498
+ to: endpointSchema,
108499
+ cardinality: exports_external.enum(["one-to-one", "one-to-many", "many-to-one", "many-to-many"]),
108500
+ description: text2.optional()
108501
+ }).strict();
108502
+ accessPatternSchema = exports_external.object({
108503
+ model: designName,
108504
+ filters: exports_external.array(identifier).max(16).optional().default([]),
108505
+ sort: exports_external.array(identifier).max(16).optional().default([]),
108506
+ description: text2.optional()
108507
+ }).strict();
108508
+ decisionSchema = exports_external.object({ name: designName, rationale: text2 }).strict();
108509
+ specSchema = exports_external.object({
108510
+ version: exports_external.literal(1),
108511
+ dialect: exports_external.enum(["postgresql", "mysql", "mariadb"]),
108512
+ models: exports_external.array(modelSchema).max(MAX_MODELS2),
108513
+ relationships: exports_external.array(relationshipSchema).max(MAX_RELATIONSHIPS2).optional().default([]),
108514
+ accessPatterns: exports_external.array(accessPatternSchema).max(MAX_ACCESS_PATTERNS).optional().default([]),
108515
+ decisions: exports_external.array(decisionSchema).max(100).optional().default([])
108516
+ }).strict();
108517
+ });
108518
+
108519
+ // src/formatters/design.ts
108520
+ function formatDesign(spec, review, format) {
108521
+ if (format === "json")
108522
+ return JSON.stringify({ spec, review }, null, 2);
108523
+ if (format === "mermaid")
108524
+ return formatMermaid(spec);
108525
+ return formatMarkdown2(spec, review);
108526
+ }
108527
+ function formatDesignReview(review, format) {
108528
+ if (format === "json")
108529
+ return JSON.stringify(review, null, 2);
108530
+ const lines = ["# Design review", ""];
108531
+ if (review.findings.length === 0)
108532
+ lines.push("No design findings.");
108533
+ else {
108534
+ lines.push("| Severity | Code | Location | Finding |", "| --- | --- | --- | --- |");
108535
+ for (const finding of review.findings) {
108536
+ lines.push(`| ${finding.severity} | ${finding.code} | ${escapeTable(finding.path)} | ${escapeTable(finding.message)} |`);
108537
+ }
108538
+ }
108539
+ lines.push("", `Summary: ${review.summary.errors} error(s), ${review.summary.warns} warning(s), ${review.summary.infos} info(s).`);
108540
+ return lines.join(`
108541
+ `);
108542
+ }
108543
+ function formatDesignProposal(plan, format) {
108544
+ if (format === "json")
108545
+ return JSON.stringify(plan, null, 2);
108546
+ const lines = ["# Design change proposal", ""];
108547
+ if (plan.proposals.length === 0)
108548
+ lines.push("No design changes need a proposal.");
108549
+ for (const proposal of plan.proposals) {
108550
+ lines.push(`## ${proposal.table}.${proposal.object}`, "", `Safety: **${proposal.safety}**`, "", "Preflight:");
108551
+ for (const step of proposal.preflight)
108552
+ lines.push(`- ${inlineCode2(step)}`);
108553
+ lines.push("", "Proposed command or escalation:");
108554
+ lines.push("```bash", ...proposal.commands, "```", "", `Rollback: ${proposal.rollback}`, "", "Verification:");
108555
+ for (const step of proposal.verification)
108556
+ lines.push(`- ${inlineCode2(step)}`);
108557
+ lines.push("");
108558
+ }
108559
+ lines.push(`Drift summary: ${plan.report.summary.errors} error(s), ${plan.report.summary.warns} warning(s).`);
108560
+ return lines.join(`
108561
+ `);
108562
+ }
108563
+ function formatMarkdown2(spec, review) {
108564
+ const lines = ["# Database Design", "", `Dialect: \`${spec.dialect}\``, ""];
108565
+ for (const model of spec.models) {
108566
+ lines.push(`## ${model.name}`, "", `Physical table: \`${model.table}\``);
108567
+ if (model.description)
108568
+ lines.push("", escapeText(model.description));
108569
+ lines.push("", "| Field | Type | Nullable | Key |", "| --- | --- | --- | --- |");
108570
+ for (const field of model.fields) {
108571
+ const key = field.primaryKey ? "primary" : field.unique ? "unique" : "";
108572
+ lines.push(`| \`${field.name}\` | \`${field.type}\` | ${field.nullable ? "yes" : "no"} | ${key} |`);
108573
+ }
108574
+ if (model.indexes.length > 0) {
108575
+ lines.push("", "Indexes:");
108576
+ for (const index of model.indexes) {
108577
+ lines.push(`- ${index.unique ? "unique " : ""}index on \`${index.columns.join(", ")}\``);
108578
+ }
108579
+ }
108580
+ lines.push("");
108581
+ }
108582
+ if (spec.relationships.length > 0) {
108583
+ lines.push("## Relationships", "");
108584
+ for (const relationship of spec.relationships) {
108585
+ lines.push(`- \`${relationship.name}\`: \`${relationship.from.model}.${relationship.from.field}\` \u2192 \`${relationship.to.model}.${relationship.to.field}\` (${relationship.cardinality})`);
108586
+ }
108587
+ lines.push("");
108588
+ }
108589
+ if (spec.accessPatterns.length > 0) {
108590
+ lines.push("## Access patterns", "");
108591
+ for (const pattern of spec.accessPatterns) {
108592
+ lines.push(`- \`${pattern.model}\`: filter ${inlineList(pattern.filters)}; sort ${inlineList(pattern.sort)}`);
108593
+ }
108594
+ lines.push("");
108595
+ }
108596
+ if (spec.decisions.length > 0) {
108597
+ lines.push("## Decisions", "");
108598
+ for (const decision of spec.decisions)
108599
+ lines.push(`- **${decision.name}** \u2014 ${escapeText(decision.rationale)}`);
108600
+ lines.push("");
108601
+ }
108602
+ lines.push(formatDesignReview(review, "markdown"));
108603
+ return lines.join(`
108604
+ `);
108605
+ }
108606
+ function formatMermaid(spec) {
108607
+ const lines = ["erDiagram"];
108608
+ for (const model of spec.models) {
108609
+ lines.push(` ${model.table} {`);
108610
+ for (const field of model.fields) {
108611
+ const markers = [field.primaryKey ? "PK" : "", field.unique && !field.primaryKey ? "UK" : ""].filter(Boolean).join(", ");
108612
+ lines.push(` ${mermaidType(field.type)} ${field.name}${markers ? ` "${markers}"` : ""}`);
108613
+ }
108614
+ lines.push(" }");
108615
+ }
108616
+ const models = new Map(spec.models.map((model) => [model.name, model]));
108617
+ for (const relationship of spec.relationships) {
108618
+ const from = models.get(relationship.from.model);
108619
+ const to = models.get(relationship.to.model);
108620
+ if (!from || !to)
108621
+ continue;
108622
+ lines.push(` ${from.table} ${mermaidCardinality(relationship.cardinality)} ${to.table} : "${relationship.name}"`);
108623
+ }
108624
+ return lines.join(`
108625
+ `);
108626
+ }
108627
+ function mermaidCardinality(cardinality) {
108628
+ if (cardinality === "one-to-one")
108629
+ return "||--||";
108630
+ if (cardinality === "one-to-many")
108631
+ return "||--o{";
108632
+ if (cardinality === "many-to-one")
108633
+ return "}o--||";
108634
+ return "}o--o{";
108635
+ }
108636
+ function mermaidType(type) {
108637
+ return type.replace(/[^A-Za-z0-9_]/g, "_");
108638
+ }
108639
+ function inlineList(values) {
108640
+ return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
108641
+ }
108642
+ function inlineCode2(value) {
108643
+ return `\`${value.replace(/`/g, "\\`")}\``;
108644
+ }
108645
+ function escapeTable(value) {
108646
+ return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r\n|\r|\n/g, "<br>");
108647
+ }
108648
+ function escapeText(value) {
108649
+ return value.replace(/([`*_{}[\]()#+.!|>~-])/g, "\\$1").replace(/\r\n|\r|\n/g, "<br>");
108650
+ }
108651
+
108652
+ // src/commands/design.ts
108653
+ function defaultFile(file) {
108654
+ return file ?? defaultDesignFile(process.cwd());
108655
+ }
108656
+ function collectOption2(value, previous) {
108657
+ return [...previous, value];
108658
+ }
108659
+ function parseIgnore(value) {
108660
+ return (value ?? "").split(",").map((pattern) => pattern.trim()).filter(Boolean);
108661
+ }
108662
+ function template(dialect) {
108663
+ return JSON.stringify({
108664
+ version: 1,
108665
+ dialect,
108666
+ models: [],
108667
+ relationships: [],
108668
+ accessPatterns: [],
108669
+ decisions: []
108670
+ }, null, 2) + `
108671
+ `;
108672
+ }
108673
+ function fail2(error, format) {
108674
+ if (error instanceof DesignValidationError) {
108675
+ const report = {
108676
+ findings: error.issues.map((issue2) => ({
108677
+ ...issue2,
108678
+ code: "INVALID_ARTIFACT",
108679
+ severity: "error"
108680
+ })),
108681
+ summary: { errors: error.issues.length, warns: 0, infos: 0 }
108682
+ };
108683
+ console.log(formatDesignReview(report, format));
108684
+ } else {
108685
+ console.error(error.message);
108686
+ }
108687
+ process.exit(1);
108688
+ }
108689
+ async function compareAgainstCache(desired, dialect, ignore, command) {
108690
+ const config = await configModule.read(resolveConfigPath(command));
108691
+ const system = config.connection?.system;
108692
+ if (!system || !["postgresql", "mysql", "mariadb"].includes(system)) {
108693
+ throw new Error("design comparison against cache requires a configured PostgreSQL, MySQL, or MariaDB connection");
108694
+ }
108695
+ if (system !== dialect) {
108696
+ throw new Error(`design dialect '${dialect}' does not match configured connection '${system}'`);
108697
+ }
108698
+ if (Object.keys(config.schema ?? {}).length === 0) {
108699
+ throw new Error("Schema cache is empty. Run 'dbcli schema' first.");
108700
+ }
108701
+ const actual = normalizeDbSchema(config.schema, system === "postgresql" ? { defaultSchema: "public" } : {});
108702
+ return compareNormalized(desired, actual, { ignore });
108703
+ }
108704
+ async function compareAgainstOrm(desired, dialect, paths, ormFormat, ignore) {
108705
+ const { schema: orm, extraDefaultIgnore } = await loadOrmSchema(parseAgainstOrmValues(paths), {
108706
+ ...ormFormat !== undefined && { ormFormat },
108707
+ system: dialect
108708
+ });
108709
+ return compareNormalized(desired, orm, {
108710
+ ignore,
108711
+ extraDefaultIgnore,
108712
+ targetLabel: "ORM definition"
108713
+ });
108714
+ }
108715
+ function isFormat(value, formats) {
108716
+ return value !== undefined && formats.includes(value);
108717
+ }
108718
+ var VALIDATE_FORMATS, RENDER_FORMATS, DIFF_FORMATS, designCommand;
108719
+ var init_design2 = __esm(() => {
108720
+ init_esm();
108721
+ init_design();
108722
+ init_config();
108723
+ init_from_db();
108724
+ init_compare();
108725
+ init_diff();
108726
+ init_config_path();
108727
+ VALIDATE_FORMATS = ["json", "markdown"];
108728
+ RENDER_FORMATS = ["json", "markdown", "mermaid"];
108729
+ DIFF_FORMATS = ["json", "table", "markdown"];
108730
+ designCommand = new Command("design").description("Validate, render, and safely review a version-controlled SQL database design");
108731
+ designCommand.command("init").description("Write an empty design artifact only to the explicitly supplied output path").requiredOption("--output <path>", "Path for the new design JSON artifact").option("--dialect <dialect>", "Target SQL dialect: postgresql, mysql, or mariadb", "postgresql").action(async (options) => {
108732
+ try {
108733
+ if (!["postgresql", "mysql", "mariadb"].includes(options.dialect)) {
108734
+ throw new Error("dialect must be postgresql, mysql, or mariadb");
108735
+ }
108736
+ const file = Bun.file(options.output);
108737
+ if (await file.exists())
108738
+ throw new Error(`refusing to overwrite existing file: ${options.output}`);
108739
+ await Bun.write(options.output, template(options.dialect));
108740
+ console.log(JSON.stringify({ status: "created", path: options.output }, null, 2));
108741
+ } catch (error) {
108742
+ console.error(error.message);
108743
+ process.exit(1);
108744
+ }
108745
+ });
108746
+ designCommand.command("diff").description("Compare a valid design against the local schema cache or local ORM definition without connecting").option("--file <path>", "Design JSON file (default: dbcli.design.json)").option("--against-cache", "Compare against the configured local schema cache").option("--against-orm <paths>", "Compare against ORM definition(s), repeatable or comma-separated; DDL supports globs", collectOption2, []).option("--orm-format <format>", "Force ORM input: prisma | ddl | json | drizzle | typeorm | sequelize").option("--ignore <globs>", "Comma-separated table globs excluded from drift").option("--format <format>", "Output format: json, table, or markdown", "json").action(async (options, command) => {
108747
+ const format = options.format;
108748
+ if (!isFormat(format, DIFF_FORMATS))
108749
+ throw new Error("format must be json, table, or markdown");
108750
+ try {
108751
+ const spec = await loadDesignSpec(defaultFile(options.file));
108752
+ const review = reviewDesign(spec);
108753
+ if (review.summary.errors > 0) {
108754
+ console.log(formatDesignReview(review, format === "table" ? "markdown" : format));
108755
+ process.exitCode = 1;
108756
+ return;
108757
+ }
108758
+ const modeCount = Number(Boolean(options.againstCache)) + Number((options.againstOrm?.length ?? 0) > 0);
108759
+ if (modeCount !== 1) {
108760
+ throw new Error("Choose exactly one of --against-cache or --against-orm");
108761
+ }
108762
+ const desired = compileDesignSchema(spec);
108763
+ const ignore = parseIgnore(options.ignore);
108764
+ const report = options.againstCache ? await compareAgainstCache(desired, spec.dialect, ignore, command) : await compareAgainstOrm(desired, spec.dialect, options.againstOrm ?? [], options.ormFormat, ignore);
108765
+ console.log(formatDrift(report, format));
108766
+ process.exitCode = report.summary.errors > 0 ? 1 : 0;
108767
+ } catch (error) {
108768
+ fail2(error, format === "table" ? "markdown" : format);
108769
+ }
108770
+ });
108771
+ designCommand.command("propose").description("Produce a review-only migration proposal from design drift; never executes DDL").option("--file <path>", "Design JSON file (default: dbcli.design.json)").option("--against-cache", "Compare against the configured local schema cache").option("--against-orm <paths>", "Compare against ORM definition(s), repeatable or comma-separated; DDL supports globs", collectOption2, []).option("--orm-format <format>", "Force ORM input: prisma | ddl | json | drizzle | typeorm | sequelize").option("--ignore <globs>", "Comma-separated table globs excluded from drift").option("--format <format>", "Output format: json or markdown", "markdown").action(async (options, command) => {
108772
+ const format = options.format;
108773
+ if (!isFormat(format, VALIDATE_FORMATS))
108774
+ throw new Error("format must be json or markdown");
108775
+ try {
108776
+ const spec = await loadDesignSpec(defaultFile(options.file));
108777
+ const review = reviewDesign(spec);
108778
+ if (review.summary.errors > 0) {
108779
+ console.log(formatDesignReview(review, format));
108780
+ process.exitCode = 1;
108781
+ return;
108782
+ }
108783
+ const modeCount = Number(Boolean(options.againstCache)) + Number((options.againstOrm?.length ?? 0) > 0);
108784
+ if (modeCount !== 1) {
108785
+ throw new Error("Choose exactly one of --against-cache or --against-orm");
108786
+ }
108787
+ const desired = compileDesignSchema(spec);
108788
+ const ignore = parseIgnore(options.ignore);
108789
+ const report = options.againstCache ? await compareAgainstCache(desired, spec.dialect, ignore, command) : await compareAgainstOrm(desired, spec.dialect, options.againstOrm ?? [], options.ormFormat, ignore);
108790
+ console.log(formatDesignProposal(planDesignProposals(report), format));
108791
+ process.exitCode = report.summary.errors > 0 ? 1 : 0;
108792
+ } catch (error) {
108793
+ fail2(error, format);
108794
+ }
108795
+ });
108796
+ designCommand.command("validate").description("Validate a local SQL design artifact without a database connection").option("--file <path>", "Design JSON file (default: dbcli.design.json)").option("--format <format>", "Output format: json or markdown", "json").action(async (options) => {
108797
+ const format = options.format;
108798
+ if (!isFormat(format, VALIDATE_FORMATS))
108799
+ throw new Error("format must be json or markdown");
108800
+ try {
108801
+ const review = reviewDesign(await loadDesignSpec(defaultFile(options.file)));
108802
+ console.log(formatDesignReview(review, format));
108803
+ process.exitCode = review.summary.errors > 0 ? 1 : 0;
108804
+ } catch (error) {
108805
+ fail2(error, format);
108806
+ }
108807
+ });
108808
+ designCommand.command("render").description("Render a valid local SQL design as JSON, Markdown, or Mermaid ERD").option("--file <path>", "Design JSON file (default: dbcli.design.json)").option("--format <format>", "Output format: json, markdown, or mermaid", "markdown").action(async (options) => {
108809
+ const format = options.format;
108810
+ if (!isFormat(format, RENDER_FORMATS))
108811
+ throw new Error("format must be json, markdown, or mermaid");
108812
+ try {
108813
+ const spec = await loadDesignSpec(defaultFile(options.file));
108814
+ const review = reviewDesign(spec);
108815
+ if (review.summary.errors > 0) {
108816
+ console.log(formatDesignReview(review, format === "mermaid" ? "markdown" : format));
108817
+ process.exitCode = 1;
108818
+ return;
108819
+ }
108820
+ console.log(formatDesign(spec, review, format));
108821
+ } catch (error) {
108822
+ fail2(error, format === "mermaid" ? "markdown" : format);
108823
+ }
108824
+ });
108825
+ });
108826
+
108082
108827
  // src/core/backfill-artifact.ts
108083
108828
  import { createHash as createHash5 } from "crypto";
108084
108829
  function isRecord3(value) {
@@ -108360,6 +109105,13 @@ function parsePositiveInteger(value) {
108360
109105
  }
108361
109106
  return parsed;
108362
109107
  }
109108
+ function parseSlowMs(value) {
109109
+ try {
109110
+ return parseSlowQueryThreshold(value);
109111
+ } catch {
109112
+ throw new InvalidArgumentError("must be a non-negative integer");
109113
+ }
109114
+ }
108363
109115
  function normalizeLimitFlags(options) {
108364
109116
  const limit = options.limit;
108365
109117
  if (typeof limit === "boolean") {
@@ -108396,7 +109148,7 @@ function buildProgram() {
108396
109148
  program2.addCommand(initCommand);
108397
109149
  program2.addCommand(listCommand);
108398
109150
  program2.addCommand(schemaCommand);
108399
- program2.command("query [sql]").description(t("query.description")).option("-f, --query-file <path>", "Read query text from a UTF-8 file, or - for stdin").option("--format <type>", "Output format: table, json, csv, html", "table").option("--ui", "Show interactive dashboard in browser", false).option("--limit <number>", "Limit result rows (overrides auto-limit)", parsePositiveInteger).option("--no-limit", "Disable auto-limit in query-only mode").option("--collection <name>", "MongoDB collection name; Elasticsearch index name").option("--index <name>", "Elasticsearch index name (alias for --collection)").option("--fields <list>", "Include fields, or exclude them with --fields=-field_a,-field_b").option("--truncate <number>", "Limit serialized table cells to N Unicode characters", parsePositiveInteger).option("--no-truncate", "Disable the default table-cell truncation").addOption(createConnectionSelectorOption()).option("--recovery", "On failure, emit a structured recovery envelope to stdout (suppresses human stderr message)", false).action(async (sql, options, command) => {
109151
+ program2.command("query [sql]").description(t("query.description")).option("-f, --query-file <path>", "Read query text from a UTF-8 file, or - for stdin").option("--format <type>", "Output format: table, json, csv, html", "table").option("--ui", "Show interactive dashboard in browser", false).option("--limit <number>", "Limit result rows (overrides auto-limit)", parsePositiveInteger).option("--no-limit", "Disable auto-limit in query-only mode").option("--collection <name>", "MongoDB collection name; Elasticsearch index name").option("--index <name>", "Elasticsearch index name (alias for --collection)").option("--fields <list>", "Include fields, or exclude them with --fields=-field_a,-field_b").option("--truncate <number>", "Limit serialized table cells to N Unicode characters", parsePositiveInteger).option("--no-truncate", "Disable the default table-cell truncation").option("--slow-ms <number>", "Emit a passive performance hint at or above this execution time; 0 disables it", parseSlowMs, 1000).addOption(createConnectionSelectorOption()).option("--recovery", "On failure, emit a structured recovery envelope to stdout (suppresses human stderr message)", false).action(async (sql, options, command) => {
108400
109152
  const rawArgs = command.parent?.rawArgs ?? [];
108401
109153
  const rawTruncate = findLongOptionValue(rawArgs, "--truncate");
108402
109154
  const rootUse = command.parent?.opts().use;
@@ -108412,7 +109164,7 @@ function buildProgram() {
108412
109164
  program2.command("plan <sql>").description("Analyze SQL risk without executing").option("--format <type>", "Output format: text, json", "text").action(async (sql, options, command) => {
108413
109165
  await planCommand(sql, options, command);
108414
109166
  });
108415
- program2.command("q <name>").description(t("q.description")).option("--format <type>", "Output format: table, json, csv, html", "table").option("--ui", "Show interactive dashboard in browser", false).option("--no-limit", "Disable size guard wrap (LIMIT 1000)").option("--dry-run", "Show final SQL + bind values; do not execute").option("--param <kv>", "Pass parameter as key=value (repeatable)", (val, prev = []) => prev.concat([val]), []).option("--param-file <path>", "JSON file containing param values").option("--recovery", "On failure, emit a structured recovery envelope to stdout (suppresses human stderr message)", false).option("--verify", "Run verification check after execution if defined", false).action(async (name2, options, command) => {
109167
+ program2.command("q <name>").description(t("q.description")).option("--format <type>", "Output format: table, json, csv, html", "table").option("--ui", "Show interactive dashboard in browser", false).option("--no-limit", "Disable size guard wrap (LIMIT 1000)").option("--dry-run", "Show final SQL + bind values; do not execute").option("--param <kv>", "Pass parameter as key=value (repeatable)", (val, prev = []) => prev.concat([val]), []).option("--param-file <path>", "JSON file containing param values").option("--slow-ms <number>", "Emit a passive performance hint at or above this execution time; 0 disables it", parseSlowMs, 1000).option("--recovery", "On failure, emit a structured recovery envelope to stdout (suppresses human stderr message)", false).option("--verify", "Run verification check after execution if defined", false).action(async (name2, options, command) => {
108416
109168
  await qCommand(name2, normalizeLimitFlags(options), command);
108417
109169
  });
108418
109170
  program2.command("insert <table>").description(t("insert.description")).option("--data <json>", "JSON object to insert").option("--dry-run", "Show generated SQL without executing").option("--force", "Skip confirmation prompt").option("--plan", "Analyze risk without connecting or executing").option("--format <type>", "Output format for --plan: text or json", "text").option("--recovery", "On failure, emit a structured recovery envelope to stdout (suppresses human stderr message)", false).action(async (table, options, command) => {
@@ -108482,6 +109234,7 @@ function buildProgram() {
108482
109234
  program2.addCommand(verifyCommand);
108483
109235
  program2.addCommand(proxyCommand);
108484
109236
  program2.addCommand(semanticCommand);
109237
+ program2.addCommand(designCommand);
108485
109238
  program2.addCommand(backfillCommand);
108486
109239
  return program2;
108487
109240
  }
@@ -108526,10 +109279,12 @@ var init_program = __esm(() => {
108526
109279
  init_use();
108527
109280
  init_proxy();
108528
109281
  init_semantic2();
109282
+ init_design2();
108529
109283
  init_backfill();
108530
109284
  init_connection_selector();
108531
109285
  init_cli_error();
108532
109286
  init_validation();
109287
+ init_slow_query_advisory();
108533
109288
  init_package();
108534
109289
  });
108535
109290
 
@@ -108655,7 +109410,7 @@ async function claimUpdateHint(configPath, kind, sessionKey, value) {
108655
109410
  // src/cli.ts
108656
109411
  init_program();
108657
109412
  init_cli_error();
108658
- import { join as join40 } from "path";
109413
+ import { join as join41 } from "path";
108659
109414
  import { writeSync as writeSync2 } from "fs";
108660
109415
  import { format } from "util";
108661
109416
  function installSynchronousRedirectedStdout() {
@@ -108784,7 +109539,7 @@ program2.hook("preAction", (thisCommand, actionCommand) => {
108784
109539
  try {
108785
109540
  let cache = null;
108786
109541
  try {
108787
- const cacheFile = Bun.file(join40(configPath, "version-check.json"));
109542
+ const cacheFile = Bun.file(join41(configPath, "version-check.json"));
108788
109543
  if (await cacheFile.exists()) {
108789
109544
  cache = await cacheFile.json();
108790
109545
  }