@carllee1983/dbcli 8.0.0 → 9.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/core.mjs CHANGED
@@ -1092,10 +1092,17 @@ var DATABASE_SYSTEMS = Object.freeze([
1092
1092
  "postgresql",
1093
1093
  "mysql",
1094
1094
  "mariadb",
1095
+ "sqlite",
1095
1096
  "mongodb",
1096
1097
  "redis",
1097
1098
  "elasticsearch"
1098
1099
  ]);
1100
+ var SQL_DATABASE_SYSTEMS = Object.freeze([
1101
+ "postgresql",
1102
+ "mysql",
1103
+ "mariadb",
1104
+ "sqlite"
1105
+ ]);
1099
1106
 
1100
1107
  class ConnectionError extends Error {
1101
1108
  code;
@@ -1247,6 +1254,38 @@ var ENGINE_CAPABILITIES = Object.freeze({
1247
1254
  ...SQL_BASE,
1248
1255
  check: cap("limited", "readonly", "SQL-only and strongest on MySQL/MariaDB.")
1249
1256
  }),
1257
+ sqlite: Object.freeze({
1258
+ ...SQL_BASE,
1259
+ init: cap("supported", "local-write", "Asks for a file path and a permission; the file must already exist."),
1260
+ use: cap("supported", "local-write", "Switches connections and displays the file path."),
1261
+ schemaFullScan: cap("unsupported", "none", "Full schema cache scan is not implemented for SQLite."),
1262
+ lint: cap("unsupported", "none", "Static lint is not enabled for the SQLite dialect yet."),
1263
+ queries: cap("supported", "local-write", "Snippets may declare `engine: sqlite` and bind with `?` placeholders."),
1264
+ insert: cap("supported", "db-write", "Row insert through the shared data-executor path."),
1265
+ update: cap("supported", "db-write", "Row update with a mandatory --where clause."),
1266
+ delete: cap("supported", "db-write", "Row delete with a mandatory --where clause."),
1267
+ export: cap("supported", "local-write", "Exports SELECT results to json, csv or html through the shared query path."),
1268
+ check: cap("unsupported", "none", "Data health check is not enabled for SQLite."),
1269
+ diff: cap("unsupported", "none", "Schema snapshots are not enabled for SQLite."),
1270
+ migrate: cap("unsupported", "none", "SQLite ALTER TABLE covers few operations and a column change is a table rebuild; no DDL generator exists."),
1271
+ shell: cap("unsupported", "none", "The REPL is not enabled for SQLite."),
1272
+ doctor: cap("supported", "readonly", "Checks the file exists, is readable, and is writable when the permission allows writes."),
1273
+ inspect: cap("unsupported", "none", "Inspection collectors are not enabled for SQLite."),
1274
+ report: cap("unsupported", "none", "Report diagnostics query engine-specific statistics views."),
1275
+ guide: cap("unsupported", "none", "Guides depend on engine-specific EXPLAIN output."),
1276
+ explain: cap("unsupported", "none", "EXPLAIN handling is not enabled for SQLite."),
1277
+ plan: cap("unsupported", "none", "Plan capture is not enabled for SQLite."),
1278
+ impactAssess: cap("unsupported", "none", "Impact assessment is not enabled for SQLite."),
1279
+ assert: cap("unsupported", "none", "Assertions are not enabled for SQLite."),
1280
+ snapshot: cap("unsupported", "none", "Result snapshots are not enabled for SQLite."),
1281
+ verify: cap("unsupported", "none", "Write verification is not enabled for SQLite."),
1282
+ semantic: cap("unsupported", "none", "Semantic context work is not enabled for SQLite."),
1283
+ design: cap("unsupported", "none", "Schema design work is not enabled for SQLite."),
1284
+ proxy: cap("not-applicable", "none", "SQLite has no network protocol to proxy."),
1285
+ proxyAnalyze: cap("not-applicable", "none", "There are no SQLite proxy events to analyse."),
1286
+ password: cap("not-applicable", "none", "A SQLite connection has no credential to store or rotate."),
1287
+ queryLimitGuard: cap("limited", "readonly", "Applies the same LIMIT rewrite as the other SQL engines; under query-only the handle is opened read-only, which a database with an unreplayed WAL cannot satisfy.")
1288
+ }),
1250
1289
  mysql: Object.freeze(SQL_BASE),
1251
1290
  mariadb: Object.freeze(SQL_BASE),
1252
1291
  mongodb: Object.freeze({
@@ -1616,7 +1655,8 @@ var messages_default = {
1616
1655
  connection_not_found: "Connection '{name}' does not exist",
1617
1656
  cannot_remove_last: "Cannot remove the last connection",
1618
1657
  connection_already_exists: "Connection '{name}' already exists",
1619
- rename_invalid_format: "Usage: --rename <old-name>:<new-name>"
1658
+ rename_invalid_format: "Usage: --rename <old-name>:<new-name>",
1659
+ prompt_sqlite_file: "SQLite database file path: "
1620
1660
  },
1621
1661
  inspect: {
1622
1662
  description: "Read-only snapshot of connection, permission, blacklist, objects, snippets, and suggested next commands.",
@@ -1785,7 +1825,8 @@ Or use interactive mode: run "dbcli init --use-env-refs" without --no-interactiv
1785
1825
  env_var_not_defined: `Cannot test connection: environment variable {envKey} is not defined.
1786
1826
  Set {envKey} in .env or environment variables.
1787
1827
  Hint: run 'export {envKey}=<value>' and retry`,
1788
- invalid_output_format: "--format {format} is not supported here \u2014 use text or json"
1828
+ invalid_output_format: "--format {format} is not supported here \u2014 use text or json",
1829
+ sqlite_file_required: "A SQLite connection needs a file path. Pass --file <path> or run init interactively."
1789
1830
  },
1790
1831
  success: {
1791
1832
  inserted: "Successfully inserted {count} row(s)",
@@ -1946,7 +1987,8 @@ var messages_default2 = {
1946
1987
  connection_not_found: "\u9023\u7DDA '{name}' \u4E0D\u5B58\u5728",
1947
1988
  cannot_remove_last: "\u7121\u6CD5\u79FB\u9664\u6700\u5F8C\u4E00\u500B\u9023\u7DDA",
1948
1989
  connection_already_exists: "\u9023\u7DDA '{name}' \u5DF2\u5B58\u5728",
1949
- rename_invalid_format: "\u7528\u6CD5\uFF1A--rename <\u820A\u540D\u7A31>:<\u65B0\u540D\u7A31>"
1990
+ rename_invalid_format: "\u7528\u6CD5\uFF1A--rename <\u820A\u540D\u7A31>:<\u65B0\u540D\u7A31>",
1991
+ prompt_sqlite_file: "SQLite \u8CC7\u6599\u5EAB\u6A94\u6848\u8DEF\u5F91\uFF1A"
1950
1992
  },
1951
1993
  inspect: {
1952
1994
  description: "\u552F\u8B80\u6AA2\u8996\uFF1A\u9023\u7DDA\u3001\u6B0A\u9650\u3001\u9ED1\u540D\u55AE\u3001\u7269\u4EF6\u3001\u7247\u6BB5\u8207\u5EFA\u8B70\u4E0B\u4E00\u6B65\u6307\u4EE4\u3002",
@@ -2115,7 +2157,8 @@ var messages_default2 = {
2115
2157
  env_var_not_defined: `\u7121\u6CD5\u6E2C\u8A66\u9023\u7DDA\uFF1A\u74B0\u5883\u8B8A\u6578 {envKey} \u672A\u5B9A\u7FA9\u3002
2116
2158
  \u8ACB\u5728 .env \u6216\u74B0\u5883\u8B8A\u6578\u4E2D\u8A2D\u5B9A {envKey}\u3002
2117
2159
  \u63D0\u793A\uFF1A\u57F7\u884C 'export {envKey}=<value>' \u5F8C\u91CD\u8A66`,
2118
- invalid_output_format: "\u7121\u6CD5\u4F7F\u7528 --format {format}\uFF0C\u8ACB\u4F7F\u7528 text \u6216 json"
2160
+ invalid_output_format: "\u7121\u6CD5\u4F7F\u7528 --format {format}\uFF0C\u8ACB\u4F7F\u7528 text \u6216 json",
2161
+ sqlite_file_required: "SQLite \u9023\u7DDA\u9700\u8981\u4E00\u500B\u6A94\u6848\u8DEF\u5F91\u3002\u8ACB\u7528 --file <path>\uFF0C\u6216\u4EE5\u4E92\u52D5\u6A21\u5F0F\u57F7\u884C init\u3002"
2119
2162
  },
2120
2163
  success: {
2121
2164
  inserted: "\u6210\u529F\u63D2\u5165 {count} \u5217",
@@ -2649,7 +2692,7 @@ function fixDoubleEncodedUtf8(str) {
2649
2692
 
2650
2693
  // src/adapters/identifier-quote.ts
2651
2694
  function quoteCharFor(dialect) {
2652
- return dialect === "postgresql" ? '"' : "`";
2695
+ return dialect === "postgresql" || dialect === "sqlite" ? '"' : "`";
2653
2696
  }
2654
2697
  function assertQuotable(name, what) {
2655
2698
  if (name.length === 0) {
@@ -3951,7 +3994,16 @@ function stripCommentsAndStrings(sql, options = {}) {
3951
3994
  continue;
3952
3995
  }
3953
3996
  }
3954
- if (mysqlDialect && char === "`") {
3997
+ if (options.dialect === "sqlite" && char === "[") {
3998
+ i++;
3999
+ while (i < sql.length && sql[i] !== "]")
4000
+ i++;
4001
+ if (i < sql.length)
4002
+ i++;
4003
+ result += " ";
4004
+ continue;
4005
+ }
4006
+ if ((mysqlDialect || options.dialect === "sqlite") && char === "`") {
3955
4007
  i++;
3956
4008
  while (i < sql.length) {
3957
4009
  if (sql[i] === "`") {
@@ -4024,8 +4076,14 @@ function extractFirstKeyword(sql) {
4024
4076
  }
4025
4077
  return firstKeyword;
4026
4078
  }
4027
- function mapKeywordToType(keyword) {
4079
+ var DECLARED_ADMIN_ONLY_KEYWORDS = new Set(["PRAGMA", "VACUUM", "REINDEX"]);
4080
+ function isDeclaredAdminOnly(keyword) {
4081
+ return DECLARED_ADMIN_ONLY_KEYWORDS.has(keyword.toUpperCase());
4082
+ }
4083
+ function mapKeywordToType(keyword, dialect) {
4028
4084
  const upper = keyword.toUpperCase();
4085
+ if (upper === "REPLACE" && dialect === "sqlite")
4086
+ return "REPLACE";
4029
4087
  if (upper === "SELECT")
4030
4088
  return "SELECT";
4031
4089
  if (upper === "SHOW")
@@ -4055,7 +4113,7 @@ function mapKeywordToType(keyword) {
4055
4113
  return "UNKNOWN";
4056
4114
  }
4057
4115
  function isDestructiveOperation(type) {
4058
- return ["DELETE", "DROP", "ALTER", "TRUNCATE", "CREATE", "GRANT"].includes(type);
4116
+ return ["DELETE", "REPLACE", "DROP", "ALTER", "TRUNCATE", "CREATE", "GRANT"].includes(type);
4059
4117
  }
4060
4118
  function extractAllKeywords(sql) {
4061
4119
  const upper = sql.toUpperCase();
@@ -4135,6 +4193,16 @@ function permissionAtLeast(actual, required) {
4135
4193
  }
4136
4194
 
4137
4195
  // src/core/permission-guard.ts
4196
+ class ConnectionBoundaryError extends Error {
4197
+ classification;
4198
+ constructor(message, classification) {
4199
+ super(message);
4200
+ this.classification = classification;
4201
+ this.name = "ConnectionBoundaryError";
4202
+ Object.setPrototypeOf(this, ConnectionBoundaryError.prototype);
4203
+ }
4204
+ }
4205
+
4138
4206
  class PermissionError extends Error {
4139
4207
  classification;
4140
4208
  requiredPermission;
@@ -4146,9 +4214,9 @@ class PermissionError extends Error {
4146
4214
  Object.setPrototypeOf(this, PermissionError.prototype);
4147
4215
  }
4148
4216
  }
4149
- function classifyStatement(sql) {
4217
+ function classifyStatement(sql, dialect) {
4150
4218
  const normalized = normalizeSQL(sql);
4151
- const stripped = stripCommentsAndStrings(normalized);
4219
+ const stripped = stripCommentsAndStrings(normalized, { dialect });
4152
4220
  const upper = stripped.toUpperCase();
4153
4221
  if (/^\s*ANALYZE\s+SELECT\b/i.test(stripped)) {
4154
4222
  return {
@@ -4161,7 +4229,16 @@ function classifyStatement(sql) {
4161
4229
  }
4162
4230
  const composite = detectCompositePatterns(upper);
4163
4231
  const firstKeyword = extractFirstKeyword(stripped);
4164
- const type = mapKeywordToType(firstKeyword);
4232
+ if (isDeclaredAdminOnly(firstKeyword)) {
4233
+ return {
4234
+ type: "UNKNOWN",
4235
+ isDangerous: true,
4236
+ keywords: extractAllKeywords(stripped),
4237
+ isComposite: false,
4238
+ confidence: "HIGH"
4239
+ };
4240
+ }
4241
+ const type = mapKeywordToType(firstKeyword, dialect);
4165
4242
  return {
4166
4243
  type,
4167
4244
  isDangerous: isDestructiveOperation(type),
@@ -4170,7 +4247,7 @@ function classifyStatement(sql) {
4170
4247
  confidence: determineConfidence(type, firstKeyword, upper)
4171
4248
  };
4172
4249
  }
4173
- var SQL_DIALECTS = ["postgresql", "mysql", "mariadb"];
4250
+ var SQL_DIALECTS = ["postgresql", "mysql", "mariadb", "sqlite"];
4174
4251
  var SQL_WRITE_OR_DDL_KEYWORDS = /(?<![.\w])(INSERT|UPDATE|DELETE|MERGE|UPSERT|REPLACE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|RENAME|INTO)\b(?!\s*\()/i;
4175
4252
  var SQL_LOCK_CLAUSE = /\bFOR\s+(?:NO\s+KEY\s+)?UPDATE\b|\bFOR\s+(?:KEY\s+)?SHARE\b/gi;
4176
4253
  function findWriteKeyword(sql, dialects) {
@@ -4207,8 +4284,22 @@ function escalateHiddenWrite(sql, classification, dialect) {
4207
4284
  escalatedFrom: hidden
4208
4285
  };
4209
4286
  }
4287
+ var SQLITE_CROSS_DATABASE = /^\s*(ATTACH|DETACH)\b/i;
4288
+ function reachesAnotherDatabase(sql, dialect) {
4289
+ if (dialect !== "sqlite")
4290
+ return false;
4291
+ return stripCommentsAndStrings(sql, { dialect }).split(";").some((statement) => SQLITE_CROSS_DATABASE.test(statement));
4292
+ }
4210
4293
  function checkPermission(sql, permission, dialect) {
4211
- const classification = escalateHiddenWrite(sql, classifyStatement(sql), dialect);
4294
+ if (reachesAnotherDatabase(sql, dialect)) {
4295
+ return {
4296
+ allowed: false,
4297
+ reason: "ATTACH and DETACH are refused on a SQLite connection: they reach a database file " + "outside the one this connection names, which the blacklist, audit log and schema " + "cache are all scoped to. Configure a second connection instead.",
4298
+ classification: classifyStatement(sql),
4299
+ boundary: true
4300
+ };
4301
+ }
4302
+ const classification = escalateHiddenWrite(sql, classifyStatement(sql, dialect), dialect);
4212
4303
  if (permission !== "admin" && containsMultipleStatements(sql, dialect)) {
4213
4304
  return {
4214
4305
  allowed: false,
@@ -4222,7 +4313,7 @@ function checkPermission(sql, permission, dialect) {
4222
4313
  var TIER_GRANTS = [
4223
4314
  { permission: "query-only", types: ["SELECT", "SHOW", "DESCRIBE", "EXPLAIN"] },
4224
4315
  { permission: "read-write", types: ["INSERT", "UPDATE"] },
4225
- { permission: "data-admin", types: ["DELETE"] }
4316
+ { permission: "data-admin", types: ["DELETE", "REPLACE"] }
4226
4317
  ];
4227
4318
  function minimumPermissionFor(type) {
4228
4319
  return TIER_GRANTS.find((tier) => tier.types.includes(type))?.permission ?? "admin";
@@ -4296,6 +4387,9 @@ function enforcePermissionForType(type, permission) {
4296
4387
  }
4297
4388
  function enforcePermission(sql, permission, dialect) {
4298
4389
  const result = checkPermission(sql, permission, dialect);
4390
+ if (result.boundary) {
4391
+ throw new ConnectionBoundaryError(result.reason, result.classification);
4392
+ }
4299
4393
  if (!result.allowed) {
4300
4394
  throw new PermissionError(result.reason, result.classification, result.requiredPermission ?? "admin");
4301
4395
  }
@@ -4577,6 +4671,18 @@ function tokenize(sql, dialect, backslashEscapes) {
4577
4671
  }
4578
4672
  continue;
4579
4673
  }
4674
+ if (dialect === "sqlite" && char === "[") {
4675
+ i++;
4676
+ let value = "";
4677
+ while (i < sql.length && sql[i] !== "]") {
4678
+ value += sql[i];
4679
+ i++;
4680
+ }
4681
+ if (i < sql.length)
4682
+ i++;
4683
+ tokens.push({ kind: "identifier", value, quoted: true });
4684
+ continue;
4685
+ }
4580
4686
  if (char === '"' || char === "`") {
4581
4687
  const quote = char;
4582
4688
  i++;
@@ -5307,7 +5413,7 @@ class DataExecutor {
5307
5413
  return this.dbSystem;
5308
5414
  }
5309
5415
  getQuoteChar() {
5310
- return this.dbSystem === "mysql" ? "`" : '"';
5416
+ return this.dbSystem === "mysql" || this.dbSystem === "mariadb" ? "`" : '"';
5311
5417
  }
5312
5418
  checkBlacklist(operation, tableName) {
5313
5419
  this.blacklistValidator?.checkTableBlacklist(operation, tableName, []);
@@ -9425,6 +9531,35 @@ var SqlConnectionConfigSchema = exports_external.object({
9425
9531
  database: StringOrEnvRef,
9426
9532
  ...TimeoutField
9427
9533
  });
9534
+ var SQLITE_MEMORY_TARGET_MESSAGE = "SQLite in-memory databases are refused: a memory target is a different database on every " + "connection, so it cannot be the connection identity the blacklist, audit log and schema " + "cache are scoped to. Point `file` at a database file on disk. See ADR-0038.";
9535
+ function isSqliteMemoryTarget(value) {
9536
+ const trimmed = value.trim();
9537
+ if (trimmed.toLowerCase() === ":memory:")
9538
+ return true;
9539
+ if (!trimmed.toLowerCase().startsWith("file:"))
9540
+ return false;
9541
+ const withoutScheme = trimmed.slice("file:".length);
9542
+ const queryStart = withoutScheme.indexOf("?");
9543
+ const path = queryStart === -1 ? withoutScheme : withoutScheme.slice(0, queryStart);
9544
+ if (path.toLowerCase() === ":memory:")
9545
+ return true;
9546
+ const query = queryStart === -1 ? "" : withoutScheme.slice(queryStart + 1);
9547
+ return new URLSearchParams(query).get("mode")?.toLowerCase() === "memory";
9548
+ }
9549
+ var SqliteConnectionConfigSchema = exports_external.object({
9550
+ system: exports_external.literal("sqlite"),
9551
+ file: exports_external.union([exports_external.string().min(1), EnvRefSchema]).superRefine((value, ctx) => {
9552
+ if (typeof value === "string" && isSqliteMemoryTarget(value)) {
9553
+ ctx.addIssue({ code: exports_external.ZodIssueCode.custom, message: SQLITE_MEMORY_TARGET_MESSAGE });
9554
+ }
9555
+ }),
9556
+ host: OptStringOrEnvRef,
9557
+ port: exports_external.union([exports_external.number().int(), EnvRefSchema]).optional().default(0),
9558
+ user: OptStringOrEnvRef,
9559
+ password: exports_external.union([exports_external.string(), EnvRefSchema]).optional().default(""),
9560
+ database: OptStringOrEnvRef,
9561
+ ...TimeoutField
9562
+ });
9428
9563
  var RedisConnectionConfigSchema = exports_external.object({
9429
9564
  system: exports_external.literal("redis"),
9430
9565
  host: StringOrEnvRef,
@@ -9451,6 +9586,7 @@ var ElasticsearchConnectionConfigSchema = exports_external.object({
9451
9586
  });
9452
9587
  var ConnectionConfigSchema = exports_external.union([
9453
9588
  SqlConnectionConfigSchema,
9589
+ SqliteConnectionConfigSchema,
9454
9590
  MongoDBConnectionConfigSchema,
9455
9591
  RedisConnectionConfigSchema,
9456
9592
  ElasticsearchConnectionConfigSchema
@@ -9521,6 +9657,11 @@ var SqlNamedConnectionSchema = SqlConnectionConfigSchema.extend({
9521
9657
  envFile: exports_external.string().optional(),
9522
9658
  environment: EnvironmentLabelSchema
9523
9659
  });
9660
+ var SqliteNamedConnectionSchema = SqliteConnectionConfigSchema.extend({
9661
+ permission: PermissionSchema,
9662
+ envFile: exports_external.string().optional(),
9663
+ environment: EnvironmentLabelSchema
9664
+ });
9524
9665
  var MongoDBNamedConnectionSchema = MongoDBConnectionConfigSchema.extend({
9525
9666
  permission: PermissionSchema,
9526
9667
  envFile: exports_external.string().optional(),
@@ -9538,6 +9679,7 @@ var ElasticsearchNamedConnectionSchema = ElasticsearchConnectionConfigSchema.ext
9538
9679
  });
9539
9680
  var NamedConnectionUnion = exports_external.union([
9540
9681
  SqlNamedConnectionSchema,
9682
+ SqliteNamedConnectionSchema,
9541
9683
  MongoDBNamedConnectionSchema,
9542
9684
  RedisNamedConnectionSchema,
9543
9685
  ElasticsearchNamedConnectionSchema
@@ -9972,6 +10114,7 @@ function listConnections(config) {
9972
10114
  port: c.port ?? 27017,
9973
10115
  database: c.database ?? "",
9974
10116
  ...c.uri !== undefined && { uri: c.uri },
10117
+ ...c.file !== undefined && { file: c.file },
9975
10118
  isDefault: name === config.default
9976
10119
  };
9977
10120
  });
@@ -10001,7 +10144,10 @@ function setDefaultConnection(config, name) {
10001
10144
  function migrateV1ToV2(v1) {
10002
10145
  const system = v1.connection.system;
10003
10146
  if (!SQL_SYSTEMS.includes(system)) {
10004
- throw new Error(`v1\u2192v2 \u81EA\u52D5\u5347\u7D1A\u76EE\u524D\u50C5\u652F\u63F4 SQL \u9023\u7DDA(mysql/postgresql/mariadb),\u4E0D\u652F\u63F4 '${system}'`);
10147
+ if (system === "sqlite") {
10148
+ throw new Error(`v1\u2192v2 \u81EA\u52D5\u5347\u7D1A\u4E0D\u63A5\u53D7 'sqlite'\uFF1Av1 \u8A2D\u5B9A\u683C\u5F0F\u65E9\u65BC SQLite \u652F\u63F4\uFF0C\u4E0D\u53EF\u80FD\u662F\u7531 dbcli \u5BEB\u51FA\u4F86\u7684\u3002` + `\u8ACB\u7528 'dbcli init --system sqlite' \u76F4\u63A5\u5EFA\u7ACB v2 \u9023\u7DDA\u3002`);
10149
+ }
10150
+ throw new Error(`v1\u2192v2 \u81EA\u52D5\u5347\u7D1A\u76EE\u524D\u50C5\u652F\u63F4 mysql/postgresql/mariadb \u7684\u9010\u6B04\u9023\u7DDA\u8A2D\u5B9A,\u4E0D\u652F\u63F4 '${system}'`);
10005
10151
  }
10006
10152
  const c = v1.connection;
10007
10153
  return {
@@ -11749,6 +11895,15 @@ function dedupe2(values) {
11749
11895
  }
11750
11896
  return result;
11751
11897
  }
11898
+ var NO_MASKING_COST = Object.freeze({
11899
+ rowsScanned: 0,
11900
+ keysScanned: 0,
11901
+ ruleEvaluations: 0,
11902
+ pathSplits: 0,
11903
+ nestedProbeRows: 0,
11904
+ nestedGlobRows: 0,
11905
+ nestedGlobMatches: 0
11906
+ });
11752
11907
  function omittedByName(omittedColumns, path) {
11753
11908
  return omittedColumns.some((omitted) => path === omitted || path.startsWith(`${omitted}.`));
11754
11909
  }
@@ -11866,13 +12021,25 @@ class BlacklistValidator {
11866
12021
  const tables = dedupe2(tableNames);
11867
12022
  const blacklistedColumns = tables.length === 0 ? this.manager.getAllBlacklistedColumns() : Array.from(new Set(tables.flatMap((table) => this.manager.getBlacklistedColumns(table))));
11868
12023
  if (blacklistedColumns.length === 0) {
11869
- return { filteredRows: rows, omittedColumns: [], reachesOmitted: () => false };
12024
+ return {
12025
+ filteredRows: rows,
12026
+ omittedColumns: [],
12027
+ cost: NO_MASKING_COST,
12028
+ reachesOmitted: () => false
12029
+ };
11870
12030
  }
12031
+ let rowsScanned = 0;
12032
+ let keysScanned = 0;
12033
+ let ruleEvaluations = 0;
12034
+ let pathSplits = 0;
12035
+ let nestedProbeRows = 0;
12036
+ let nestedGlobRows = 0;
11871
12037
  const probeNested = blacklistedColumns.some((path) => path.includes("."));
11872
12038
  const presentColumns = new Set(columnList);
11873
12039
  const nestedHeads = new Set;
11874
12040
  const collect = (record) => {
11875
12041
  for (const key of Object.getOwnPropertyNames(record)) {
12042
+ keysScanned += 1;
11876
12043
  presentColumns.add(key);
11877
12044
  if (!probeNested)
11878
12045
  continue;
@@ -11882,6 +12049,7 @@ class BlacklistValidator {
11882
12049
  }
11883
12050
  };
11884
12051
  for (const row of rows) {
12052
+ rowsScanned += 1;
11885
12053
  if (row === null || typeof row !== "object")
11886
12054
  continue;
11887
12055
  if (Array.isArray(row)) {
@@ -11904,6 +12072,7 @@ class BlacklistValidator {
11904
12072
  }
11905
12073
  const omitted = new Set;
11906
12074
  for (const path of blacklistedColumns) {
12075
+ ruleEvaluations += 1;
11907
12076
  const present = presentByFolded.get(foldFieldPath(path));
11908
12077
  if (present !== undefined) {
11909
12078
  for (const name of present)
@@ -11915,8 +12084,17 @@ class BlacklistValidator {
11915
12084
  continue;
11916
12085
  if (!nestedHeads.has(foldFieldPath(path.slice(0, dot))))
11917
12086
  continue;
12087
+ pathSplits += 1;
11918
12088
  const segments = path.split(".").map(foldFieldPath);
11919
- if (rows.some((row) => hasFieldPath(row, segments, PRE_FOLDED)))
12089
+ let found = false;
12090
+ for (const row of rows) {
12091
+ nestedProbeRows += 1;
12092
+ if (hasFieldPath(row, segments, PRE_FOLDED)) {
12093
+ found = true;
12094
+ break;
12095
+ }
12096
+ }
12097
+ if (found)
11920
12098
  omitted.add(path);
11921
12099
  }
11922
12100
  const nestedGlobs = globRules.filter((pattern) => pattern.segments.length > 1);
@@ -11927,6 +12105,7 @@ class BlacklistValidator {
11927
12105
  for (const row of rows) {
11928
12106
  if (pending.size === 0)
11929
12107
  break;
12108
+ nestedGlobRows += 1;
11930
12109
  if (row === null || typeof row !== "object")
11931
12110
  continue;
11932
12111
  collectNestedMatches(row, states, pending, (pattern) => {
@@ -11964,13 +12143,35 @@ class BlacklistValidator {
11964
12143
  }
11965
12144
  const omittedColumns = Array.from(omitted);
11966
12145
  if (omittedColumns.length === 0) {
11967
- return { filteredRows: rows, omittedColumns: [], reachesOmitted: () => false };
12146
+ return {
12147
+ filteredRows: rows,
12148
+ omittedColumns: [],
12149
+ cost: Object.freeze({
12150
+ rowsScanned,
12151
+ keysScanned,
12152
+ ruleEvaluations,
12153
+ pathSplits,
12154
+ nestedProbeRows,
12155
+ nestedGlobRows,
12156
+ nestedGlobMatches: matchedNested.length
12157
+ }),
12158
+ reachesOmitted: () => false
12159
+ };
11968
12160
  }
11969
12161
  const removalPaths = matchedNested.length === 0 ? omittedColumns : omittedColumns.filter((path) => !matchedNested.some((p) => p.raw === path));
11970
12162
  const filteredRows = omitFieldPaths(rows, removalPaths, matchedNested.length > 0 ? { ...FOLD_CASE2, patterns: matchedNested } : FOLD_CASE2);
11971
12163
  return {
11972
12164
  filteredRows,
11973
12165
  omittedColumns,
12166
+ cost: Object.freeze({
12167
+ rowsScanned,
12168
+ keysScanned,
12169
+ ruleEvaluations,
12170
+ pathSplits,
12171
+ nestedProbeRows,
12172
+ nestedGlobRows,
12173
+ nestedGlobMatches: matchedNested.length
12174
+ }),
11974
12175
  reachesOmitted: (path) => {
11975
12176
  if (omittedByName(removalPaths, path))
11976
12177
  return true;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "8.0.0",
3
+ "version": "9.1.0",
4
4
  "description": "Database CLI skill and command reference for AI agents.",
5
5
  "contextFileName": "AGENTS.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carllee1983/dbcli",
3
- "version": "8.0.0",
3
+ "version": "9.1.0",
4
4
  "description": "Database CLI for AI agents",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -81,15 +81,15 @@
81
81
  "services:check": "bun run scripts/check-test-services.ts",
82
82
  "docs:check": "bun run scripts/check-user-docs.ts",
83
83
  "contract:check": "bun run scripts/check-cli-contract.ts",
84
- "skill:check": "bun run scripts/check-skill-parity.ts",
85
84
  "platform:check": "bun run scripts/check-platform-parity.ts",
86
85
  "agent-core:check": "bun run scripts/check-agent-core-purity.ts",
87
86
  "core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
88
87
  "plan:check": "bun run scripts/check-plan-acceptance.ts",
89
88
  "forgeflow:check": "bun run scripts/check-forgeflow-adoption.ts && bun run scripts/check-forgeflow-handoff.ts",
89
+ "forgeflow:contract": "bun run scripts/check-forgeflow-contract.ts",
90
90
  "typecheck": "tsc --noEmit --pretty false",
91
91
  "typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
92
- "test:perf": "bun test ./tests/perf/*.bench.ts",
92
+ "test:perf": "bun test --timeout 30000 ./tests/perf/*.bench.ts",
93
93
  "lint": "eslint src tests scripts --ext .ts --max-warnings=0",
94
94
  "format:check": "prettier --check .",
95
95
  "format": "prettier --write .",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbcli-agent",
3
- "version": "8.0.0",
3
+ "version": "9.1.0",
4
4
  "description": "Database CLI skill and command reference for AI agents",
5
5
  "author": {
6
6
  "name": "Carl Lee",
@@ -3137,7 +3137,7 @@ dbcli skill --install codex # install to ~/.codex/skills/dbcli/
3137
3137
  **Options:**
3138
3138
  - `--install <platform>` — `claude` | `gemini` | `antigravity` | `copilot` | `cursor` | `codex` | `windsurf`. Writes `SKILL.md` plus `reference.md` next to it so the agent gets progressive disclosure.
3139
3139
  - `--output <path>` — write `SKILL.md` to a file instead of stdout. Does not install `reference.md`.
3140
- - `--lang <en|zh-TW>` — source language for the emitted SKILL content (default `en`). It selects `assets/SKILL.md` vs `assets/SKILL.zh-TW.md`; the install/output filename stays `SKILL.md` regardless.
3140
+ - Skill content is always the canonical English `assets/SKILL.md`; the install/output filename is `SKILL.md`.
3141
3141
 
3142
3142
  **Notes:**
3143
3143
  - Both files come straight from `assets/SKILL.md` + `assets/reference.md` inside the dbcli package — no runtime rendering. Keep these in sync when shipping a release.
@@ -3872,7 +3872,7 @@ GET /orders/_search
3872
3872
 
3873
3873
  - Enter a request line `<METHOD> /<path>`, then an optional multi-line JSON body; a **blank line** submits the block. Responses render as pretty-printed JSON.
3874
3874
  - **The connection's permission tier applies, as it does to `dbcli query`.** The request is classified through the same classifier the query path uses, on the path the server will actually route — the query string is discarded and percent-encoding and dot segments are resolved first, so `?filter_path=...` cannot change the verdict.
3875
- - **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/0014-elasticsearch-reads-are-an-allowlist.md).
3875
+ - **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/ADR-0014-elasticsearch-reads-are-an-allowlist.md).
3876
3876
  - A path is refused unless it is **byte-identical** to what the URL parser produces — the same parser the request goes through — and the refusal tells you the canonical spelling to write instead. This is what stops a path that reads one way here and routes another way at the server: `#` truncates a path in transit (`POST /_reindex#/_count` reaches Elasticsearch as `POST /_reindex`), and tab, newline and `\` are rewritten too. A document id containing a space or a non-ASCII character must be written percent-encoded; the error message gives you the exact string.
3877
3877
  - A quoted string request body is refused; write the body as JSON. So is a `source=` query parameter — Elasticsearch accepts it in place of a body, where none of the body checks can see it. `_source`, `_source_includes` and `_source_excludes` are unaffected.
3878
3878
  - Blacklisted field names are refused in the query string as well as the body, because the URI-search form names fields directly (`?q=…`, `?sort=…`, `?docvalue_fields=…`). A name matches on any dot component, so `password.keyword` — the multi-field every `text` field gets by default — and `params._source.password` inside a script are both refused.
@@ -3137,7 +3137,7 @@ dbcli skill --install codex # install to ~/.codex/skills/dbcli/
3137
3137
  **Options:**
3138
3138
  - `--install <platform>` — `claude` | `gemini` | `antigravity` | `copilot` | `cursor` | `codex` | `windsurf`. Writes `SKILL.md` plus `reference.md` next to it so the agent gets progressive disclosure.
3139
3139
  - `--output <path>` — write `SKILL.md` to a file instead of stdout. Does not install `reference.md`.
3140
- - `--lang <en|zh-TW>` — source language for the emitted SKILL content (default `en`). It selects `assets/SKILL.md` vs `assets/SKILL.zh-TW.md`; the install/output filename stays `SKILL.md` regardless.
3140
+ - Skill content is always the canonical English `assets/SKILL.md`; the install/output filename is `SKILL.md`.
3141
3141
 
3142
3142
  **Notes:**
3143
3143
  - Both files come straight from `assets/SKILL.md` + `assets/reference.md` inside the dbcli package — no runtime rendering. Keep these in sync when shipping a release.
@@ -3872,7 +3872,7 @@ GET /orders/_search
3872
3872
 
3873
3873
  - Enter a request line `<METHOD> /<path>`, then an optional multi-line JSON body; a **blank line** submits the block. Responses render as pretty-printed JSON.
3874
3874
  - **The connection's permission tier applies, as it does to `dbcli query`.** The request is classified through the same classifier the query path uses, on the path the server will actually route — the query string is discarded and percent-encoding and dot segments are resolved first, so `?filter_path=...` cannot change the verdict.
3875
- - **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/0014-elasticsearch-reads-are-an-allowlist.md).
3875
+ - **Reads are an allowlist; anything else needs `admin`.** Permitted below `admin`: `_search` and `_count` on an index or unscoped, `_doc` / `_source` reads, `_mapping` / `_settings` / `_alias` reads, `_cat/*` (except `_cat/aliases` and `_cat/tasks`), `_cluster/health`, and `GET` / `HEAD` of a bare index name. Writes to one document are the usual tiers. **Everything else — including any endpoint not listed here — requires `admin`**, so `DELETE /<index>`, `DELETE /_all`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_aliases`, `PUT /<index>/_mapping`, `PUT /<index>/_settings`, `_sql`, `_scripts`, `_security`, `_snapshot`, `_nodes` and `_cluster/state` are all refused below it. The list is a floor, not a claim that everything absent from it is dangerous: a missing entry costs you an unnecessary `admin` requirement rather than a bypass. See [ADR-0014](https://github.com/CarlLee1983/dbcli/blob/main/docs/adr/ADR-0014-elasticsearch-reads-are-an-allowlist.md).
3876
3876
  - A path is refused unless it is **byte-identical** to what the URL parser produces — the same parser the request goes through — and the refusal tells you the canonical spelling to write instead. This is what stops a path that reads one way here and routes another way at the server: `#` truncates a path in transit (`POST /_reindex#/_count` reaches Elasticsearch as `POST /_reindex`), and tab, newline and `\` are rewritten too. A document id containing a space or a non-ASCII character must be written percent-encoded; the error message gives you the exact string.
3877
3877
  - A quoted string request body is refused; write the body as JSON. So is a `source=` query parameter — Elasticsearch accepts it in place of a body, where none of the body checks can see it. `_source`, `_source_includes` and `_source_excludes` are unaffected.
3878
3878
  - Blacklisted field names are refused in the query string as well as the body, because the URI-search form names fields directly (`?q=…`, `?sort=…`, `?docvalue_fields=…`). A name matches on any dot component, so `password.keyword` — the multi-field every `text` field gets by default — and `params._source.password` inside a script are both refused.