@syntopica/db-quality 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -71,7 +71,7 @@ var dataSourceType = (url) => {
71
71
  };
72
72
 
73
73
  // src/config/isDisabled.ts
74
- var isDisabled = (code, disabled) => disabled.includes(code);
74
+ var isDisabled = (code, disabled) => disabled.some((entry) => entry.code === code);
75
75
 
76
76
  // src/model/fingerprintFinding.ts
77
77
  import { createHash } from "crypto";
@@ -166,7 +166,7 @@ var runSoda = (runner, root, soda, disabled) => {
166
166
  var ADVISOR_SEVERITY = {
167
167
  ERROR: "error",
168
168
  WARN: "warn",
169
- INFO: "info"
169
+ INFO: "warn"
170
170
  };
171
171
 
172
172
  // src/adapters/supabase/parseAdvisorReport.ts
@@ -268,7 +268,7 @@ var parseIndexStats = (stdout2, disabled) => {
268
268
  ).map((row) => {
269
269
  const partial = {
270
270
  code: "BDB601",
271
- severity: "info",
271
+ severity: "warn",
272
272
  path: "supabase",
273
273
  line: 0,
274
274
  message: `index has never been scanned (${row.size})`,
@@ -368,13 +368,16 @@ var reportCommandError = (error, stderr2) => {
368
368
  return configuration ? ExitCode.CONFIGURATION : ExitCode.INFRASTRUCTURE;
369
369
  };
370
370
 
371
+ // src/config/CONFIG_FILENAME.ts
372
+ var CONFIG_FILENAME = "codeality-db.json";
373
+
374
+ // src/config/legacyConfigNotice.ts
375
+ var legacyConfigNotice = (config) => config.schemaVersion === 1 ? `${CONFIG_FILENAME} is schemaVersion 1; "codeality-db init --apply" upgrades it to 2 and asks a reason for every disabled rule` : void 0;
376
+
371
377
  // src/config/readConfig.ts
372
378
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
373
379
  import { join as join3 } from "path";
374
380
 
375
- // src/config/CONFIG_FILENAME.ts
376
- var CONFIG_FILENAME = "codeality-db.json";
377
-
378
381
  // src/config/expectConfig.ts
379
382
  var expectConfig = (condition, message) => {
380
383
  if (!condition) throw new ConfigError(message);
@@ -408,7 +411,9 @@ var CONFIG_KEYS = /* @__PURE__ */ new Set([
408
411
  "prisma",
409
412
  "drizzle",
410
413
  "sqlite",
414
+ "postgrest",
411
415
  "audit",
416
+ "perf",
412
417
  "disable"
413
418
  ]);
414
419
 
@@ -422,9 +427,103 @@ var configSection = (raw, name) => {
422
427
  return value;
423
428
  };
424
429
 
430
+ // src/config/LEGACY_DISABLE_REASON.ts
431
+ var LEGACY_DISABLE_REASON = "carried over from schemaVersion 1; write the real reason";
432
+
433
+ // src/config/disableEntryFrom.ts
434
+ var disableEntryFrom = (raw, schemaVersion) => {
435
+ if (typeof raw === "string") {
436
+ if (schemaVersion === 1) return { code: raw, reason: LEGACY_DISABLE_REASON };
437
+ throw new ConfigError(
438
+ `disable entries must be { "code": "${raw}", "reason": "why" } under schemaVersion 2`
439
+ );
440
+ }
441
+ if (typeof raw !== "object" || raw === null)
442
+ throw new ConfigError("disable entries must be objects");
443
+ const { code, reason } = raw;
444
+ if (typeof code !== "string")
445
+ throw new ConfigError("disable code must be a string");
446
+ if (typeof reason !== "string" || reason.trim() === "")
447
+ throw new ConfigError(`disable reason must not be empty (${code})`);
448
+ return { code, reason };
449
+ };
450
+
451
+ // src/config/disableEntriesFrom.ts
452
+ var disableEntriesFrom = (raw, schemaVersion) => {
453
+ if (raw === void 0) return [];
454
+ if (!Array.isArray(raw)) throw new ConfigError("disable must be a list");
455
+ return raw.map((entry) => disableEntryFrom(entry, schemaVersion));
456
+ };
457
+
458
+ // src/config/isPositiveInteger.ts
459
+ var isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
460
+
425
461
  // src/config/isStringList.ts
426
462
  var isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
427
463
 
464
+ // src/config/PERF_DEFAULTS.ts
465
+ var PERF_DEFAULTS = {
466
+ inGate: false,
467
+ slowMs: 100,
468
+ regressionPercent: 20,
469
+ minCalls: 20,
470
+ seqScanRows: 1e4,
471
+ benchDir: "db-quality/bench",
472
+ benchRuns: 5,
473
+ benchTimeoutMs: 6e4,
474
+ roles: ["authenticator", "service_role", "postgres"],
475
+ ignore: []
476
+ };
477
+
478
+ // src/config/ROLE_NAME_PATTERN.ts
479
+ var ROLE_NAME_PATTERN = /^[a-z_][a-z0-9_]*$/;
480
+
481
+ // src/config/perfSectionFrom.ts
482
+ var perfSectionFrom = (raw) => {
483
+ const merged = { ...PERF_DEFAULTS, ...raw };
484
+ for (const key of [
485
+ "slowMs",
486
+ "regressionPercent",
487
+ "minCalls",
488
+ "seqScanRows",
489
+ "benchRuns",
490
+ "benchTimeoutMs"
491
+ ])
492
+ expectConfig(
493
+ typeof merged[key] === "number",
494
+ `perf.${key} must be a number`
495
+ );
496
+ for (const key of ["minCalls", "benchRuns", "benchTimeoutMs"])
497
+ expectConfig(
498
+ isPositiveInteger(merged[key]),
499
+ `perf.${key} must be a positive integer`
500
+ );
501
+ expectConfig(
502
+ typeof merged["inGate"] === "boolean",
503
+ "perf.inGate must be a boolean"
504
+ );
505
+ expectConfig(
506
+ typeof merged["benchDir"] === "string",
507
+ "perf.benchDir must be a string"
508
+ );
509
+ expectConfig(
510
+ isStringList(merged["roles"]),
511
+ "perf.roles must be a list of strings"
512
+ );
513
+ expectConfig(
514
+ isStringList(merged["ignore"]),
515
+ "perf.ignore must be a list of strings"
516
+ );
517
+ const roles = merged["roles"];
518
+ expectConfig(roles.length > 0, "perf.roles must name at least one role");
519
+ const bad = roles.find((role) => !ROLE_NAME_PATTERN.test(role));
520
+ if (bad !== void 0)
521
+ throw new ConfigError(
522
+ `perf.roles entries must match ${ROLE_NAME_PATTERN.source}: "${bad}"`
523
+ );
524
+ return merged;
525
+ };
526
+
428
527
  // src/config/drizzleSectionFrom.ts
429
528
  var drizzleSectionFrom = (drizzle) => {
430
529
  const roots = drizzle["roots"];
@@ -437,6 +536,16 @@ var drizzleSectionFrom = (drizzle) => {
437
536
  return { roots, objectNames };
438
537
  };
439
538
 
539
+ // src/config/postgrestSectionFrom.ts
540
+ var postgrestSectionFrom = (raw) => {
541
+ const roots = raw["roots"];
542
+ if (!isStringList(roots))
543
+ throw new ConfigError("postgrest.roots must be a list of strings");
544
+ if (roots.length === 0)
545
+ throw new ConfigError("postgrest.roots must name at least one directory");
546
+ return { roots };
547
+ };
548
+
440
549
  // src/config/stackSectionsFrom.ts
441
550
  var stackSectionsFrom = (raw) => {
442
551
  const stacks = {};
@@ -463,6 +572,8 @@ var stackSectionsFrom = (raw) => {
463
572
  throw new ConfigError("sqlite.files must be a list of strings");
464
573
  stacks.sqlite = { files };
465
574
  }
575
+ const postgrest = configSection(raw, "postgrest");
576
+ if (postgrest) stacks.postgrest = postgrestSectionFrom(postgrest);
466
577
  return stacks;
467
578
  };
468
579
 
@@ -475,16 +586,15 @@ var configFromDocument = (document) => {
475
586
  for (const key of Object.keys(raw)) {
476
587
  if (!CONFIG_KEYS.has(key)) throw new ConfigError(`unknown key "${key}"`);
477
588
  }
478
- if (raw["schemaVersion"] !== 1)
479
- throw new ConfigError("schemaVersion must be 1");
480
- const disable = raw["disable"] ?? [];
481
- if (!isStringList(disable))
482
- throw new ConfigError("disable must be a list of strings");
589
+ const schemaVersion = raw["schemaVersion"];
590
+ if (schemaVersion !== 1 && schemaVersion !== 2)
591
+ throw new ConfigError("schemaVersion must be 1 or 2");
483
592
  return {
484
- schemaVersion: 1,
593
+ schemaVersion,
485
594
  ...stackSectionsFrom(raw),
486
595
  audit: auditSectionFrom(configSection(raw, "audit")),
487
- disable
596
+ perf: perfSectionFrom(configSection(raw, "perf")),
597
+ disable: disableEntriesFrom(raw["disable"], schemaVersion)
488
598
  };
489
599
  };
490
600
 
@@ -516,12 +626,15 @@ var auditCommand = (argv2, io) => {
516
626
  "db-url": { type: "string" }
517
627
  });
518
628
  const config = readConfig(io.root);
629
+ const notice = legacyConfigNotice(config);
630
+ if (notice) io.stderr(`${notice}
631
+ `);
519
632
  const target = resolveAuditTarget(io.root, {
520
633
  linked: values["linked"] === true,
521
634
  "db-url": values["db-url"]
522
635
  });
523
- for (const notice of auditPlan(config, target).skipped)
524
- io.stderr(`${notice}
636
+ for (const notice2 of auditPlan(config, target).skipped)
637
+ io.stderr(`${notice2}
525
638
  `);
526
639
  const findings = runAudit({
527
640
  root: io.root,
@@ -540,8 +653,8 @@ var auditCommand = (argv2, io) => {
540
653
  };
541
654
 
542
655
  // src/commands/baselineCommand.ts
543
- import { existsSync as existsSync5 } from "fs";
544
- import { join as join7 } from "path";
656
+ import { existsSync as existsSync6 } from "fs";
657
+ import { join as join9 } from "path";
545
658
 
546
659
  // src/baseline/BASELINE_FILENAME.ts
547
660
  var BASELINE_FILENAME = ".codeality-db-baseline.json";
@@ -913,6 +1026,471 @@ var runSquawk = (runner, root, set, disabled) => {
913
1026
  return parseSquawkReport(result.stdout, set, disabled);
914
1027
  };
915
1028
 
1029
+ // src/postgrest/runPostgrestRules.ts
1030
+ import { readFileSync as readFileSync4 } from "fs";
1031
+ import { join as join7 } from "path";
1032
+
1033
+ // src/postgrest/ITERATION_METHODS.ts
1034
+ var ITERATION_METHODS = /* @__PURE__ */ new Set([
1035
+ "map",
1036
+ "forEach",
1037
+ "reduce",
1038
+ "filter",
1039
+ "flatMap"
1040
+ ]);
1041
+
1042
+ // src/postgrest/isIterationCallback.ts
1043
+ var isIterationCallback = (compiler, node) => (compiler.isArrowFunction(node) || compiler.isFunctionExpression(node)) && compiler.isCallExpression(node.parent) && compiler.isPropertyAccessExpression(node.parent.expression) && ITERATION_METHODS.has(node.parent.expression.name.text);
1044
+
1045
+ // src/postgrest/isInsideLoop.ts
1046
+ var isInsideLoop = (compiler, node) => {
1047
+ let current = node.parent;
1048
+ while (!compiler.isSourceFile(current)) {
1049
+ if (compiler.isIterationStatement(current, false)) return true;
1050
+ if (isIterationCallback(compiler, current)) return true;
1051
+ if (compiler.isFunctionLike(current)) return false;
1052
+ current = current.parent;
1053
+ }
1054
+ return false;
1055
+ };
1056
+
1057
+ // src/postgrest/isStorageReceiver.ts
1058
+ var isStorageReceiver = (compiler, expression) => compiler.isPropertyAccessExpression(expression) && expression.name.text === "storage";
1059
+
1060
+ // src/postgrest/isStringLiteralArgument.ts
1061
+ var isStringLiteralArgument = (compiler, node) => node !== void 0 && (compiler.isStringLiteral(node) || compiler.isNoSubstitutionTemplateLiteral(node));
1062
+
1063
+ // src/postgrest/literalArgument.ts
1064
+ var literalArgument = (compiler, node) => {
1065
+ if (compiler.isStringLiteral(node) || compiler.isNoSubstitutionTemplateLiteral(node))
1066
+ return node.text;
1067
+ if (!compiler.isObjectLiteralExpression(node)) return "?";
1068
+ const pairs = node.properties.map((property) => {
1069
+ if (!compiler.isPropertyAssignment(property)) return "?";
1070
+ const key = compiler.isIdentifier(property.name) || compiler.isStringLiteral(property.name) ? property.name.text : "?";
1071
+ const value = property.initializer;
1072
+ const text = compiler.isStringLiteral(value) || compiler.isNumericLiteral(value) ? value.text : value.kind === compiler.SyntaxKind.TrueKeyword ? "true" : value.kind === compiler.SyntaxKind.FalseKeyword ? "false" : "?";
1073
+ return `${key}:${text}`;
1074
+ });
1075
+ return `{${pairs.join(",")}}`;
1076
+ };
1077
+
1078
+ // src/postgrest/chainFromCall.ts
1079
+ var chainFromCall = (compiler, outer, file, path) => {
1080
+ const calls = [];
1081
+ let current = outer;
1082
+ while (compiler.isCallExpression(current) && compiler.isPropertyAccessExpression(current.expression)) {
1083
+ calls.unshift({
1084
+ name: current.expression.name.text,
1085
+ args: current.arguments.map((node) => literalArgument(compiler, node)),
1086
+ node: current
1087
+ });
1088
+ current = current.expression.expression;
1089
+ }
1090
+ const rootIndex = calls.findIndex(
1091
+ (call) => (call.name === "from" || call.name === "rpc") && isStringLiteralArgument(compiler, call.node.arguments[0])
1092
+ );
1093
+ const root = calls[rootIndex];
1094
+ if (!root) return void 0;
1095
+ if (rootIndex === 0 && isStorageReceiver(compiler, current)) return void 0;
1096
+ const rest = calls.slice(rootIndex + 1).map(({ name, args }) => ({ name, args }));
1097
+ return {
1098
+ path,
1099
+ line: file.getLineAndCharacterOfPosition(root.node.getStart(file)).line + 1,
1100
+ root: root.name,
1101
+ target: root.args[0] ?? "",
1102
+ calls: rest,
1103
+ inLoop: isInsideLoop(compiler, outer),
1104
+ text: outer.getText(file).replaceAll(/\s+/g, " ")
1105
+ };
1106
+ };
1107
+
1108
+ // src/postgrest/isInnerCall.ts
1109
+ var isInnerCall = (compiler, node) => compiler.isPropertyAccessExpression(node.parent) && compiler.isCallExpression(node.parent.parent) && node.parent.parent.expression === node.parent;
1110
+
1111
+ // src/postgrest/collectPostgrestChains.ts
1112
+ var collectPostgrestChains = (compiler, path, source) => {
1113
+ const file = compiler.createSourceFile(
1114
+ path,
1115
+ source,
1116
+ compiler.ScriptTarget.Latest,
1117
+ true,
1118
+ path.endsWith(".tsx") ? compiler.ScriptKind.TSX : compiler.ScriptKind.TS
1119
+ );
1120
+ const chains = [];
1121
+ const visit = (node) => {
1122
+ if (compiler.isCallExpression(node) && !isInnerCall(compiler, node)) {
1123
+ const chain = chainFromCall(compiler, node, file, path);
1124
+ if (chain) chains.push(chain);
1125
+ }
1126
+ compiler.forEachChild(node, visit);
1127
+ };
1128
+ visit(file);
1129
+ return chains;
1130
+ };
1131
+
1132
+ // src/postgrest/loadTypeScript.ts
1133
+ import { createRequire } from "module";
1134
+ var loadTypeScript = (bases) => {
1135
+ for (const base of bases) {
1136
+ try {
1137
+ return createRequire(base)("typescript");
1138
+ } catch (error) {
1139
+ if (error.code !== "MODULE_NOT_FOUND")
1140
+ throw error;
1141
+ }
1142
+ }
1143
+ throw new ToolMissingError(
1144
+ "typescript",
1145
+ "install typescript in the project to run the PostgREST rules"
1146
+ );
1147
+ };
1148
+
1149
+ // src/postgrest/chainSelectCall.ts
1150
+ var chainSelectCall = (chain) => {
1151
+ const first = chain.calls[0];
1152
+ return chain.root === "from" && first?.name === "select" ? first : void 0;
1153
+ };
1154
+
1155
+ // src/postgrest/makePostgrestFinding.ts
1156
+ var makePostgrestFinding = (rule, chain, subject, message) => {
1157
+ const partial = {
1158
+ code: rule.code,
1159
+ severity: rule.severity,
1160
+ path: chain.path,
1161
+ line: chain.line,
1162
+ message,
1163
+ subject
1164
+ };
1165
+ return { ...partial, fingerprint: fingerprintFinding(partial, chain.text) };
1166
+ };
1167
+
1168
+ // src/postgrest/exactCountUnbounded.ts
1169
+ var exactCountUnbounded = {
1170
+ code: "BDB805",
1171
+ name: "exact-count-unbounded",
1172
+ severity: "warn",
1173
+ run: (chain) => {
1174
+ const options = chainSelectCall(chain)?.args[1] ?? "";
1175
+ if (!options.includes("count:exact") || options.includes("head:true"))
1176
+ return void 0;
1177
+ if (chain.calls.some((call) => call.name === "limit" || call.name === "range"))
1178
+ return void 0;
1179
+ return makePostgrestFinding(
1180
+ exactCountUnbounded,
1181
+ chain,
1182
+ chain.target,
1183
+ "count: exact with no limit or range counts and returns the whole table; use head: true for the count alone or bound the rows"
1184
+ );
1185
+ }
1186
+ };
1187
+
1188
+ // src/postgrest/FILTER_METHODS.ts
1189
+ var FILTER_METHODS = /* @__PURE__ */ new Set([
1190
+ "eq",
1191
+ "neq",
1192
+ "gt",
1193
+ "gte",
1194
+ "lt",
1195
+ "lte",
1196
+ "like",
1197
+ "ilike",
1198
+ "is",
1199
+ "in",
1200
+ "contains",
1201
+ "containedBy",
1202
+ "overlaps",
1203
+ "textSearch"
1204
+ ]);
1205
+
1206
+ // src/postgrest/filterWithoutIndex.ts
1207
+ var filterWithoutIndex = {
1208
+ code: "BDB803",
1209
+ name: "filter-without-index",
1210
+ severity: "warn",
1211
+ run: (chain, context) => {
1212
+ if (chain.root !== "from") return void 0;
1213
+ const knowledge = context.knowledge.get(`public.${chain.target}`);
1214
+ if (!knowledge) return void 0;
1215
+ const filter = chain.calls.find(
1216
+ (call) => FILTER_METHODS.has(call.name) && call.args[0] !== void 0 && call.args[0] !== "?" && !call.args[0].includes(".") && !knowledge.indexed.has(call.args[0].toLowerCase())
1217
+ );
1218
+ if (!filter) return void 0;
1219
+ const column = filter.args[0] ?? "";
1220
+ return makePostgrestFinding(
1221
+ filterWithoutIndex,
1222
+ chain,
1223
+ `${chain.target}.${column}`,
1224
+ `${filter.name}('${column}') filters a column no migration indexes: a sequential scan on every call`
1225
+ );
1226
+ }
1227
+ };
1228
+
1229
+ // src/postgrest/queryInLoop.ts
1230
+ var queryInLoop = {
1231
+ code: "BDB804",
1232
+ name: "query-in-loop",
1233
+ severity: "warn",
1234
+ run: (chain) => chain.inLoop ? makePostgrestFinding(
1235
+ queryInLoop,
1236
+ chain,
1237
+ chain.target,
1238
+ "query runs once per iteration: fetch the set with .in() or a join, or move the query out of the loop"
1239
+ ) : void 0
1240
+ };
1241
+
1242
+ // src/postgrest/starSelect.ts
1243
+ var starSelect = {
1244
+ code: "BDB801",
1245
+ name: "select-star",
1246
+ severity: "warn",
1247
+ run: (chain) => {
1248
+ const select = chainSelectCall(chain);
1249
+ if (!select) return void 0;
1250
+ const list = select.args[0];
1251
+ if (list !== void 0 && list !== "?" && !list.includes("*"))
1252
+ return void 0;
1253
+ if (list === "?") return void 0;
1254
+ return makePostgrestFinding(
1255
+ starSelect,
1256
+ chain,
1257
+ chain.target,
1258
+ "select fetches every column: name the columns the caller reads, so a new wide column never travels for free"
1259
+ );
1260
+ }
1261
+ };
1262
+
1263
+ // src/postgrest/BOUNDING_METHODS.ts
1264
+ var BOUNDING_METHODS = /* @__PURE__ */ new Set([
1265
+ "limit",
1266
+ "range",
1267
+ "single",
1268
+ "maybeSingle",
1269
+ "csv"
1270
+ ]);
1271
+
1272
+ // src/postgrest/isBounded.ts
1273
+ var isBounded = (chain, context) => {
1274
+ if (chain.calls.some((call) => BOUNDING_METHODS.has(call.name))) return true;
1275
+ if (chainSelectCall(chain)?.args[1]?.includes("head:true")) return true;
1276
+ const unique = context.knowledge.get(`public.${chain.target}`)?.unique;
1277
+ return chain.calls.some(
1278
+ (call) => call.name === "eq" && call.args[0] !== void 0 && unique?.has(call.args[0].toLowerCase()) === true
1279
+ );
1280
+ };
1281
+
1282
+ // src/postgrest/unboundedList.ts
1283
+ var unboundedList = {
1284
+ code: "BDB802",
1285
+ name: "unbounded-list",
1286
+ severity: "warn",
1287
+ run: (chain, context) => {
1288
+ if (!chainSelectCall(chain) || !context.knowledge.has(`public.${chain.target}`))
1289
+ return void 0;
1290
+ if (isBounded(chain, context)) return void 0;
1291
+ return makePostgrestFinding(
1292
+ unboundedList,
1293
+ chain,
1294
+ chain.target,
1295
+ "read has no limit, range, single or unique-key equality: it returns the whole table as it grows"
1296
+ );
1297
+ }
1298
+ };
1299
+
1300
+ // src/postgrest/POSTGREST_RULES.ts
1301
+ var POSTGREST_RULES = [
1302
+ starSelect,
1303
+ unboundedList,
1304
+ filterWithoutIndex,
1305
+ queryInLoop,
1306
+ exactCountUnbounded
1307
+ ];
1308
+
1309
+ // src/postgrest/sourceFilesUnder.ts
1310
+ import { readdirSync } from "fs";
1311
+ import { join as join6, relative as relative2 } from "path";
1312
+
1313
+ // src/config/isDirectory.ts
1314
+ import { existsSync as existsSync5, statSync } from "fs";
1315
+ var isDirectory = (path) => existsSync5(path) && statSync(path).isDirectory();
1316
+
1317
+ // src/postgrest/SKIPPED_SOURCE_DIRS.ts
1318
+ var SKIPPED_SOURCE_DIRS = /* @__PURE__ */ new Set([
1319
+ "node_modules",
1320
+ "dist",
1321
+ "build",
1322
+ ".next",
1323
+ "coverage",
1324
+ "__tests__",
1325
+ "__mocks__"
1326
+ ]);
1327
+
1328
+ // src/postgrest/sourceFilesUnder.ts
1329
+ var sourceFilesUnder = (root, roots) => {
1330
+ for (const base of roots) {
1331
+ if (!isDirectory(join6(root, base)))
1332
+ throw new ConfigError(`postgrest.roots: "${base}" is not a directory`);
1333
+ }
1334
+ const files = [];
1335
+ const walk = (dir) => {
1336
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1337
+ if (entry.isDirectory()) {
1338
+ if (!SKIPPED_SOURCE_DIRS.has(entry.name) && !entry.name.startsWith("."))
1339
+ walk(join6(dir, entry.name));
1340
+ continue;
1341
+ }
1342
+ const name = entry.name;
1343
+ if (!/\.tsx?$/.test(name) || name.endsWith(".d.ts") || /\.(?:test|spec)\.tsx?$/.test(name))
1344
+ continue;
1345
+ files.push(relative2(root, join6(dir, name)));
1346
+ }
1347
+ };
1348
+ for (const base of roots) walk(join6(root, base));
1349
+ return files.sort();
1350
+ };
1351
+
1352
+ // src/postgrest/runPostgrestRules.ts
1353
+ var runPostgrestRules = (root, roots, knowledge, disabled) => {
1354
+ const rules = POSTGREST_RULES.filter(
1355
+ (rule) => !isDisabled(rule.code, disabled)
1356
+ );
1357
+ const context = { knowledge };
1358
+ const compiler = loadTypeScript([join7(root, "package.json"), import.meta.url]);
1359
+ return sourceFilesUnder(root, roots).flatMap(
1360
+ (path) => collectPostgrestChains(
1361
+ compiler,
1362
+ path,
1363
+ readFileSync4(join7(root, path), "utf8")
1364
+ )
1365
+ ).flatMap((chain) => rules.map((rule) => rule.run(chain, context))).filter((finding) => finding !== void 0).sort(compareFindings);
1366
+ };
1367
+
1368
+ // src/sql/qualifiedName.ts
1369
+ var qualifiedName = (raw) => {
1370
+ const parts = raw.trim().split(".").map((part) => part.replaceAll('"', "").toLowerCase());
1371
+ return parts.length === 1 ? `public.${parts[0] ?? ""}` : parts.join(".");
1372
+ };
1373
+
1374
+ // src/rules/matchedTables.ts
1375
+ var matchedTables = (set, pattern) => {
1376
+ const tables = /* @__PURE__ */ new Map();
1377
+ for (const file of set)
1378
+ for (const statement of file.statements) {
1379
+ const match = pattern.exec(normalizeSqlText(statement.text));
1380
+ if (!match?.[1]) continue;
1381
+ const name = qualifiedName(match[1]);
1382
+ if (!tables.has(name)) tables.set(name, { file, statement });
1383
+ }
1384
+ return tables;
1385
+ };
1386
+
1387
+ // src/rules/createdTables.ts
1388
+ var createdTables = (set) => new Map(
1389
+ [
1390
+ ...matchedTables(
1391
+ set,
1392
+ /^create (?:unlogged |temp(?:orary)? )?table (?:if not exists )?((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)/
1393
+ )
1394
+ ].filter(([name]) => name.startsWith("public."))
1395
+ );
1396
+
1397
+ // src/rules/ALTER_KEY_PATTERN.ts
1398
+ var ALTER_KEY_PATTERN = /^alter table (?:if exists )?(?:only )?((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?) add (?:constraint \S+ )?(primary key|unique)\s*\(([^)]*)\)/;
1399
+
1400
+ // src/rules/leadingColumn.ts
1401
+ var leadingColumn = (list) => {
1402
+ const first = list.split(",")[0]?.trim() ?? "";
1403
+ const match = /^(?:"([^"]+)"|([a-z_][\w$]*))(?:\s+(?:asc|desc|nulls|[a-z_]+_ops)\b.*)?$/i.exec(
1404
+ first
1405
+ );
1406
+ if (!match) return void 0;
1407
+ return (match[1] ?? match[2] ?? "").toLowerCase();
1408
+ };
1409
+
1410
+ // src/rules/recordAlterKey.ts
1411
+ var recordAlterKey = (text, entry) => {
1412
+ const match = ALTER_KEY_PATTERN.exec(text);
1413
+ if (!match?.[1] || match[3] === void 0) return false;
1414
+ const column = leadingColumn(match[3]);
1415
+ if (column === void 0) return true;
1416
+ const table = entry(qualifiedName(match[1]));
1417
+ table.indexed.add(column);
1418
+ table.unique.add(column);
1419
+ return true;
1420
+ };
1421
+
1422
+ // src/rules/CREATE_INDEX_PATTERN.ts
1423
+ var CREATE_INDEX_PATTERN = /^create (unique )?index (?:concurrently )?(?:if not exists )?(?:\S+ )?on (?:only )?((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)(?: using \w+)?\s*\(([^)]*)\)/;
1424
+
1425
+ // src/rules/recordCreateIndex.ts
1426
+ var recordCreateIndex = (text, entry) => {
1427
+ const match = CREATE_INDEX_PATTERN.exec(text);
1428
+ if (!match?.[2] || match[3] === void 0) return false;
1429
+ const column = leadingColumn(match[3]);
1430
+ if (column === void 0) return true;
1431
+ const table = entry(qualifiedName(match[2]));
1432
+ table.indexed.add(column);
1433
+ if (match[1]) table.unique.add(column);
1434
+ return true;
1435
+ };
1436
+
1437
+ // src/rules/CREATE_TABLE_PATTERN.ts
1438
+ var CREATE_TABLE_PATTERN = /^create (?:unlogged |temp(?:orary)? )?table (?:if not exists )?((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)\s*\(([\s\S]*)\)/;
1439
+
1440
+ // src/rules/inlineKeyColumns.ts
1441
+ var inlineKeyColumns = (body) => {
1442
+ const columns = [];
1443
+ for (const part of body.split(/,(?![^(]*\))/)) {
1444
+ const item = part.trim();
1445
+ const constraint = /^(?:constraint\s+\S+\s+)?(?:primary key|unique)\s*\(([^)]*)\)/i.exec(
1446
+ item
1447
+ );
1448
+ if (constraint?.[1]) {
1449
+ const head = constraint[1].split(",")[0]?.trim().replaceAll('"', "").toLowerCase();
1450
+ if (head) columns.push(head);
1451
+ continue;
1452
+ }
1453
+ const column = /^(?:"([^"]+)"|([a-z_][\w$]*))\s[^,]*\b(?:primary key|unique)\b/i.exec(
1454
+ item
1455
+ );
1456
+ if (column) columns.push((column[1] ?? column[2] ?? "").toLowerCase());
1457
+ }
1458
+ return columns;
1459
+ };
1460
+
1461
+ // src/rules/recordCreateTable.ts
1462
+ var recordCreateTable = (text, entry) => {
1463
+ const match = CREATE_TABLE_PATTERN.exec(text);
1464
+ if (!match?.[1] || match[2] === void 0) return false;
1465
+ const table = entry(qualifiedName(match[1]));
1466
+ for (const column of inlineKeyColumns(match[2])) {
1467
+ table.indexed.add(column);
1468
+ table.unique.add(column);
1469
+ }
1470
+ return true;
1471
+ };
1472
+
1473
+ // src/rules/indexedColumns.ts
1474
+ var indexedColumns = (set) => {
1475
+ const knowledge = /* @__PURE__ */ new Map();
1476
+ const entry = (table) => {
1477
+ const existing = knowledge.get(table);
1478
+ if (existing) return existing;
1479
+ const fresh = { indexed: /* @__PURE__ */ new Set(), unique: /* @__PURE__ */ new Set() };
1480
+ knowledge.set(table, fresh);
1481
+ return fresh;
1482
+ };
1483
+ for (const table of createdTables(set).keys()) entry(table);
1484
+ for (const file of set)
1485
+ for (const statement of file.statements) {
1486
+ const text = normalizeSqlText(statement.text);
1487
+ if (recordCreateTable(text, entry)) continue;
1488
+ if (recordCreateIndex(text, entry)) continue;
1489
+ recordAlterKey(text, entry);
1490
+ }
1491
+ return knowledge;
1492
+ };
1493
+
916
1494
  // src/rules/makeSqlFinding.ts
917
1495
  var makeSqlFinding = (rule, { file, statement }, subject, message) => {
918
1496
  const partial = {
@@ -929,12 +1507,6 @@ var makeSqlFinding = (rule, { file, statement }, subject, message) => {
929
1507
  };
930
1508
  };
931
1509
 
932
- // src/sql/qualifiedName.ts
933
- var qualifiedName = (raw) => {
934
- const parts = raw.trim().split(".").map((part) => part.replaceAll('"', "").toLowerCase());
935
- return parts.length === 1 ? `public.${parts[0] ?? ""}` : parts.join(".");
936
- };
937
-
938
1510
  // src/rules/policyTable.ts
939
1511
  var policyTable = (statement) => {
940
1512
  const match = /^create policy .+? on ((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)/.exec(
@@ -1020,19 +1592,6 @@ var policyTables = (set) => {
1020
1592
  return tables;
1021
1593
  };
1022
1594
 
1023
- // src/rules/matchedTables.ts
1024
- var matchedTables = (set, pattern) => {
1025
- const tables = /* @__PURE__ */ new Map();
1026
- for (const file of set)
1027
- for (const statement of file.statements) {
1028
- const match = pattern.exec(normalizeSqlText(statement.text));
1029
- if (!match?.[1]) continue;
1030
- const name = qualifiedName(match[1]);
1031
- if (!tables.has(name)) tables.set(name, { file, statement });
1032
- }
1033
- return tables;
1034
- };
1035
-
1036
1595
  // src/rules/rlsEnabledTables.ts
1037
1596
  var rlsEnabledTables = (set) => matchedTables(
1038
1597
  set,
@@ -1043,7 +1602,7 @@ var rlsEnabledTables = (set) => matchedTables(
1043
1602
  var rlsEnabledNoPolicy = {
1044
1603
  code: "BDB002",
1045
1604
  name: "rls-enabled-no-policy",
1046
- severity: "info",
1605
+ severity: "warn",
1047
1606
  run: (set) => {
1048
1607
  const withPolicy = policyTables(set);
1049
1608
  return [...rlsEnabledTables(set)].filter(([table]) => !withPolicy.has(table)).map(
@@ -1057,16 +1616,6 @@ var rlsEnabledNoPolicy = {
1057
1616
  }
1058
1617
  };
1059
1618
 
1060
- // src/rules/createdTables.ts
1061
- var createdTables = (set) => new Map(
1062
- [
1063
- ...matchedTables(
1064
- set,
1065
- /^create (?:unlogged |temp(?:orary)? )?table (?:if not exists )?((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))?)/
1066
- )
1067
- ].filter(([name]) => name.startsWith("public."))
1068
- );
1069
-
1070
1619
  // src/rules/tableWithoutRls.ts
1071
1620
  var tableWithoutRls = {
1072
1621
  code: "BDB003",
@@ -1098,8 +1647,8 @@ var SQL_RULES = [
1098
1647
  var runSqlRules = (set, disabled) => SQL_RULES.filter((rule) => !isDisabled(rule.code, disabled)).flatMap((rule) => rule.run(set)).sort(compareFindings);
1099
1648
 
1100
1649
  // src/sql/readMigrationSet.ts
1101
- import { readdirSync, readFileSync as readFileSync4 } from "fs";
1102
- import { join as join6 } from "path";
1650
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
1651
+ import { join as join8 } from "path";
1103
1652
 
1104
1653
  // src/sql/quotedSpanEnd.ts
1105
1654
  var quotedSpanEnd = (sql, index) => {
@@ -1168,18 +1717,18 @@ var splitSqlStatements = (sql) => {
1168
1717
  };
1169
1718
 
1170
1719
  // src/sql/readMigrationSet.ts
1171
- var readMigrationSet = (root, migrationsDir) => readdirSync(join6(root, migrationsDir)).filter((name) => name.endsWith(".sql")).sort().map((name) => ({
1720
+ var readMigrationSet = (root, migrationsDir) => readdirSync2(join8(root, migrationsDir)).filter((name) => name.endsWith(".sql")).sort().map((name) => ({
1172
1721
  path: `${migrationsDir.replaceAll("\\", "/")}/${name}`,
1173
1722
  statements: splitSqlStatements(
1174
- readFileSync4(join6(root, migrationsDir, name), "utf8")
1723
+ readFileSync5(join8(root, migrationsDir, name), "utf8")
1175
1724
  )
1176
1725
  }));
1177
1726
 
1178
1727
  // src/check/runCheck.ts
1179
1728
  var runCheck = ({ root, config, runner }) => {
1180
1729
  const findings = [];
1730
+ const set = config.supabase ? readMigrationSet(root, config.supabase.migrations) : [];
1181
1731
  if (config.supabase) {
1182
- const set = readMigrationSet(root, config.supabase.migrations);
1183
1732
  findings.push(
1184
1733
  ...runSqlRules(set, config.disable),
1185
1734
  ...runSquawk(runner, root, set, config.disable)
@@ -1199,12 +1748,22 @@ var runCheck = ({ root, config, runner }) => {
1199
1748
  ...runSqliteChecks(runner, root, config.sqlite.files, config.disable)
1200
1749
  );
1201
1750
  }
1751
+ if (config.postgrest) {
1752
+ findings.push(
1753
+ ...runPostgrestRules(
1754
+ root,
1755
+ config.postgrest.roots,
1756
+ indexedColumns(set),
1757
+ config.disable
1758
+ )
1759
+ );
1760
+ }
1202
1761
  return findings.sort(compareFindings);
1203
1762
  };
1204
1763
 
1205
1764
  // src/packageVersion.ts
1206
- import { createRequire } from "module";
1207
- var PACKAGE_VERSION = createRequire(import.meta.url)("../package.json").version;
1765
+ import { createRequire as createRequire2 } from "module";
1766
+ var PACKAGE_VERSION = createRequire2(import.meta.url)("../package.json").version;
1208
1767
 
1209
1768
  // src/commands/baselineCommand.ts
1210
1769
  var baselineCommand = (argv2, io) => {
@@ -1213,14 +1772,18 @@ var baselineCommand = (argv2, io) => {
1213
1772
  "check-stale": { type: "boolean" }
1214
1773
  });
1215
1774
  const action = baselineActionFrom(positionals);
1216
- if (action === "create" && existsSync5(join7(io.root, BASELINE_FILENAME))) {
1775
+ if (action === "create" && existsSync6(join9(io.root, BASELINE_FILENAME))) {
1217
1776
  throw new ConfigError(
1218
1777
  `${BASELINE_FILENAME} exists; use "baseline update" to rewrite it`
1219
1778
  );
1220
1779
  }
1780
+ const config = readConfig(io.root);
1781
+ const notice = legacyConfigNotice(config);
1782
+ if (notice) io.stderr(`${notice}
1783
+ `);
1221
1784
  const findings = runCheck({
1222
1785
  root: io.root,
1223
- config: readConfig(io.root),
1786
+ config,
1224
1787
  runner: io.runner
1225
1788
  });
1226
1789
  if (action !== "check") {
@@ -1250,9 +1813,13 @@ var baselineCommand = (argv2, io) => {
1250
1813
  var checkCommand = (argv2, io) => {
1251
1814
  try {
1252
1815
  const { values } = parseCommandArgs(argv2, { json: { type: "boolean" } });
1816
+ const config = readConfig(io.root);
1817
+ const notice = legacyConfigNotice(config);
1818
+ if (notice) io.stderr(`${notice}
1819
+ `);
1253
1820
  const findings = runCheck({
1254
1821
  root: io.root,
1255
- config: readConfig(io.root),
1822
+ config,
1256
1823
  runner: io.runner
1257
1824
  });
1258
1825
  io.stdout(
@@ -1275,62 +1842,790 @@ var gateExitCode = (results) => {
1275
1842
  };
1276
1843
 
1277
1844
  // src/gate/gateStages.ts
1278
- import { existsSync as existsSync6 } from "fs";
1279
- import { join as join8 } from "path";
1280
- var gateStages = (context) => {
1281
- const hasBaseline = existsSync6(join8(context.root, BASELINE_FILENAME));
1282
- const linked = existsSync6(join8(context.root, "supabase/.temp/project-ref"));
1283
- return [
1284
- hasBaseline ? {
1285
- name: "baseline-check",
1286
- run: () => classifyFindings(runCheck(context), readBaseline(context.root)).new
1287
- } : { name: "check", run: () => runCheck(context) },
1288
- {
1289
- name: "audit",
1290
- run: () => context.config.audit.inGate && linked ? runAudit({ ...context, target: { linked: true } }) : "not-applicable"
1291
- }
1292
- ];
1293
- };
1845
+ import { existsSync as existsSync11 } from "fs";
1846
+ import { join as join15 } from "path";
1294
1847
 
1295
- // src/gate/renderGateReport.ts
1296
- var renderGateReport = (results) => results.flatMap((result) => [
1297
- `${result.status.padStart(22)} ${result.name.padEnd(14)}${result.durationSeconds.toFixed(2).padStart(8)}s`,
1298
- ...result.status === "passed" || result.status === "skipped-not-applicable" || !result.detail ? [] : result.detail.split("\n").map((line) => ` ${line}`)
1299
- ]).join("\n");
1848
+ // src/gate/perfStage.ts
1849
+ import { existsSync as existsSync10 } from "fs";
1850
+ import { join as join14 } from "path";
1300
1851
 
1301
- // src/gate/runStage.ts
1302
- import { performance } from "perf_hooks";
1303
- var runStage = (stage) => {
1304
- const started = performance.now();
1305
- const done = (status, detail) => ({
1306
- name: stage.name,
1307
- status,
1308
- durationSeconds: (performance.now() - started) / 1e3,
1309
- detail
1310
- });
1311
- try {
1312
- const outcome = stage.run();
1313
- if (outcome === "not-applicable") return done("skipped-not-applicable", "");
1314
- return outcome.length === 0 ? done("passed", "") : done("findings", renderFindings(outcome));
1315
- } catch (error) {
1316
- return done(
1317
- "failed-to-run",
1318
- error instanceof Error ? error.message : String(error)
1852
+ // src/bench/BENCH_RECORD_FILENAME.ts
1853
+ var BENCH_RECORD_FILENAME = ".codeality-db-bench.json";
1854
+
1855
+ // src/bench/readBenchRecord.ts
1856
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
1857
+ import { join as join10 } from "path";
1858
+ var readBenchRecord = (root) => {
1859
+ const path = join10(root, BENCH_RECORD_FILENAME);
1860
+ if (!existsSync7(path)) return void 0;
1861
+ const document = JSON.parse(
1862
+ readFileSync6(path, "utf8")
1863
+ );
1864
+ if (document.schemaVersion !== 1)
1865
+ throw new ConfigError(
1866
+ `${BENCH_RECORD_FILENAME} is not a version 1 bench record`
1319
1867
  );
1320
- }
1868
+ return {
1869
+ schemaVersion: 1,
1870
+ toolVersion: document.toolVersion ?? "",
1871
+ takenAt: document.takenAt ?? "",
1872
+ host: document.host ?? "",
1873
+ entries: document.entries ?? {}
1874
+ };
1321
1875
  };
1322
1876
 
1323
- // src/gate/runGate.ts
1324
- var runGate = (stages) => stages.map(runStage);
1325
-
1326
- // src/commands/gateCommand.ts
1877
+ // src/bench/benchFinding.ts
1878
+ var benchFinding = (input, code, subject, message) => {
1879
+ const partial = {
1880
+ code,
1881
+ severity: code === "BDB913" ? "warn" : "error",
1882
+ path: `${input.benchDir}/${input.file}`,
1883
+ line: 1,
1884
+ message,
1885
+ subject
1886
+ };
1887
+ return { ...partial, fingerprint: fingerprintFinding(partial, input.file) };
1888
+ };
1889
+
1890
+ // src/bench/ESTIMATE_RATIO_LIMIT.ts
1891
+ var ESTIMATE_RATIO_LIMIT = 100;
1892
+
1893
+ // src/perf/REGRESSION_FLOOR_MS.ts
1894
+ var REGRESSION_FLOOR_MS = 5;
1895
+
1896
+ // src/bench/benchFindings.ts
1897
+ var benchFindings = (input, recorded, current, judgement) => {
1898
+ const { perf, disabled } = judgement;
1899
+ const findings = [];
1900
+ const add = (code, subject, message) => {
1901
+ findings.push(benchFinding(input, code, subject, message));
1902
+ };
1903
+ const slower = recorded && current.medianMs >= recorded.medianMs * (1 + perf.regressionPercent / 100) && current.medianMs - recorded.medianMs >= REGRESSION_FLOOR_MS;
1904
+ if (slower)
1905
+ add(
1906
+ "BDB911",
1907
+ input.file,
1908
+ `median ${recorded.medianMs.toFixed(2)} ms -> ${current.medianMs.toFixed(2)} ms (+${String(Math.round((current.medianMs / recorded.medianMs - 1) * 100))}%) over ${String(current.runs)} runs`
1909
+ );
1910
+ const newScans = recorded ? current.seqScans.filter(
1911
+ (scan) => !recorded.seqScans.some((r) => r.relation === scan.relation) && (scan.rows >= perf.seqScanRows || recorded.indexScans.includes(scan.relation))
1912
+ ) : [];
1913
+ for (const scan of newScans)
1914
+ add(
1915
+ "BDB912",
1916
+ scan.relation,
1917
+ `sequential scan over ${scan.rows.toLocaleString("en-US")} rows of "${scan.relation}" where the record had ${recorded?.indexScans.includes(scan.relation) ? "an index scan" : "none"}`
1918
+ );
1919
+ if (current.worstEstimateRatio >= ESTIMATE_RATIO_LIMIT)
1920
+ add(
1921
+ "BDB913",
1922
+ input.file,
1923
+ `planner estimate off by ${String(Math.round(current.worstEstimateRatio))}x: run ANALYZE or raise the statistics target`
1924
+ );
1925
+ return findings.filter((f) => !isDisabled(f.code, disabled));
1926
+ };
1927
+
1928
+ // src/bench/readBenchQueries.ts
1929
+ import { readdirSync as readdirSync3, readFileSync as readFileSync7 } from "fs";
1930
+ import { join as join11 } from "path";
1931
+
1932
+ // src/bench/RUNS_HEADER.ts
1933
+ var RUNS_HEADER = /^--\s*runs:\s*(\S+)/;
1934
+
1935
+ // src/bench/readBenchQueries.ts
1936
+ var readBenchQueries = (root, benchDir, defaultRuns) => {
1937
+ const dir = join11(root, benchDir);
1938
+ if (!isDirectory(dir)) throw new ConfigError(`${benchDir} is not a directory`);
1939
+ return readdirSync3(dir).filter((name) => name.endsWith(".sql")).sort().map((name) => {
1940
+ const raw = readFileSync7(join11(dir, name), "utf8");
1941
+ const match = RUNS_HEADER.exec(raw);
1942
+ const runs = match?.[1] ? Number(match[1]) : defaultRuns;
1943
+ if (!isPositiveInteger(runs))
1944
+ throw new ConfigError(`${name}: -- runs: must be a positive integer`);
1945
+ const statements = splitSqlStatements(raw);
1946
+ const sql = statements[0]?.text;
1947
+ if (sql === void 0) throw new ConfigError(`${name} holds no statement`);
1948
+ if (statements.length > 1)
1949
+ throw new ConfigError(
1950
+ `${name} holds more than one statement; a bench file is one statement`
1951
+ );
1952
+ return { file: name, sql, runs };
1953
+ });
1954
+ };
1955
+
1956
+ // src/bench/median.ts
1957
+ var median = (values) => {
1958
+ const sorted = [...values].sort((a, b) => a - b);
1959
+ const mid = Math.floor(sorted.length / 2);
1960
+ if (sorted.length % 2 === 1) return sorted[mid] ?? 0;
1961
+ return ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
1962
+ };
1963
+
1964
+ // src/bench/ESTIMATE_MIN_ROWS.ts
1965
+ var ESTIMATE_MIN_ROWS = 1e3;
1966
+
1967
+ // src/bench/walkPlan.ts
1968
+ var walkPlan = (node, visit) => {
1969
+ visit(node);
1970
+ for (const child of node.Plans ?? []) walkPlan(child, visit);
1971
+ };
1972
+
1973
+ // src/bench/summarizeExplain.ts
1974
+ var summarizeExplain = (explainJson) => {
1975
+ const scanKinds = ["Index Scan", "Index Only Scan", "Bitmap Heap Scan"];
1976
+ const parsed = JSON.parse(explainJson);
1977
+ const first = Array.isArray(parsed) ? parsed[0] : void 0;
1978
+ if (typeof first !== "object" || first === null || !("Plan" in first) || !("Execution Time" in first))
1979
+ throw new Error("psql output is not an EXPLAIN document");
1980
+ const document = first;
1981
+ const seqScans = [];
1982
+ const indexScans = [];
1983
+ let worstEstimateRatio = 0;
1984
+ walkPlan(document.Plan, (node) => {
1985
+ const relation = node["Relation Name"];
1986
+ if (node["Node Type"] === "Seq Scan" && relation)
1987
+ seqScans.push({
1988
+ relation,
1989
+ rows: (node["Actual Rows"] + (node["Rows Removed by Filter"] ?? 0)) * node["Actual Loops"]
1990
+ });
1991
+ if (relation && scanKinds.includes(node["Node Type"]))
1992
+ indexScans.push(relation);
1993
+ const plan = node["Plan Rows"];
1994
+ const actual = node["Actual Rows"];
1995
+ if (plan > 0 && actual > 0 && Math.max(plan, actual) >= ESTIMATE_MIN_ROWS) {
1996
+ const ratio = Math.max(
1997
+ (plan + 1) / (actual + 1),
1998
+ (actual + 1) / (plan + 1)
1999
+ );
2000
+ if (ratio > worstEstimateRatio) worstEstimateRatio = ratio;
2001
+ }
2002
+ });
2003
+ return {
2004
+ executionMs: document["Execution Time"],
2005
+ seqScans,
2006
+ indexScans,
2007
+ worstEstimateRatio
2008
+ };
2009
+ };
2010
+
2011
+ // src/bench/runBenchQuery.ts
2012
+ var runBenchQuery = (session, query) => {
2013
+ const times = [];
2014
+ let last;
2015
+ try {
2016
+ session.explain(query.sql);
2017
+ for (let run = 0; run < query.runs; run += 1) {
2018
+ last = summarizeExplain(session.explain(query.sql));
2019
+ times.push(last.executionMs);
2020
+ }
2021
+ } catch (error) {
2022
+ throw new Error(`${query.file}: ${error.message}`, {
2023
+ cause: error
2024
+ });
2025
+ }
2026
+ if (!last) throw new Error(`${query.file}: zero runs`);
2027
+ return {
2028
+ medianMs: median(times),
2029
+ minMs: Math.min(...times),
2030
+ runs: query.runs,
2031
+ seqScans: last.seqScans,
2032
+ indexScans: last.indexScans,
2033
+ worstEstimateRatio: last.worstEstimateRatio
2034
+ };
2035
+ };
2036
+
2037
+ // src/bench/runBench.ts
2038
+ var runBench = (session, root, recorded, judgement) => {
2039
+ const { perf } = judgement;
2040
+ const queries = readBenchQueries(root, perf.benchDir, perf.benchRuns);
2041
+ const entries = {};
2042
+ const findings = [];
2043
+ const improvements = [];
2044
+ for (const query of queries) {
2045
+ const entry = runBenchQuery(session, query);
2046
+ entries[query.file] = entry;
2047
+ const recordedEntry = recorded?.entries[query.file];
2048
+ findings.push(
2049
+ ...benchFindings(
2050
+ { file: query.file, benchDir: perf.benchDir },
2051
+ recordedEntry,
2052
+ entry,
2053
+ judgement
2054
+ )
2055
+ );
2056
+ if (recordedEntry && entry.medianMs <= recordedEntry.medianMs * (1 - perf.regressionPercent / 100))
2057
+ improvements.push({
2058
+ subject: query.file,
2059
+ previousMs: recordedEntry.medianMs,
2060
+ currentMs: entry.medianMs,
2061
+ calls: entry.runs,
2062
+ savedMs: recordedEntry.medianMs - entry.medianMs
2063
+ });
2064
+ }
2065
+ return { entries, findings, improvements };
2066
+ };
2067
+
2068
+ // src/postgres/dollarTag.ts
2069
+ import { randomBytes } from "crypto";
2070
+ var dollarTag = (text, next = () => randomBytes(8).toString("hex")) => {
2071
+ for (; ; ) {
2072
+ const tag = `dbq_${next()}`;
2073
+ if (!text.includes(`$${tag}$`)) return tag;
2074
+ }
2075
+ };
2076
+
2077
+ // src/postgres/PLAN_SETTING.ts
2078
+ var PLAN_SETTING = "dbq.plan";
2079
+
2080
+ // src/postgres/ROLLBACK_SQLSTATE.ts
2081
+ var ROLLBACK_SQLSTATE = "DBQRB";
2082
+
2083
+ // src/postgres/explainDoBlock.ts
2084
+ var explainDoBlock = (sql) => {
2085
+ const quote = dollarTag(sql);
2086
+ const body = dollarTag(`${sql}$${quote}$`);
2087
+ return `do $${body}$ declare c refcursor; p text; begin begin open c for execute 'explain (analyze, buffers, format json) ' || $${quote}$${sql}$${quote}$; fetch c into p; close c; raise exception using errcode = '${ROLLBACK_SQLSTATE}'; exception when sqlstate '${ROLLBACK_SQLSTATE}' then null; end; if p is null then raise exception 'the statement produced no plan'; end if; perform set_config('${PLAN_SETTING}', p, false); end $${body}$`;
2088
+ };
2089
+
2090
+ // src/postgres/psqlArguments.ts
2091
+ var psqlArguments = (url, timeoutMs, statements) => [
2092
+ url,
2093
+ "-X",
2094
+ "-q",
2095
+ "-A",
2096
+ "-t",
2097
+ "-v",
2098
+ "ON_ERROR_STOP=1",
2099
+ "-c",
2100
+ "set default_transaction_read_only = on",
2101
+ "-c",
2102
+ `set statement_timeout = ${String(timeoutMs)}`,
2103
+ ...statements.flatMap((statement) => ["-c", statement])
2104
+ ];
2105
+
2106
+ // src/postgres/redactConnectionSecrets.ts
2107
+ var redactConnectionSecrets = (text) => text.replace(/(\w+:\/\/[^:/\s@]+:)[^@\s]+@/g, "$1***@");
2108
+
2109
+ // src/postgres/psqlSession.ts
2110
+ var psqlSession = (runner, root, target, timeoutMs) => {
2111
+ const run = (statements) => {
2112
+ const result = runner(
2113
+ "psql",
2114
+ psqlArguments(target.url, timeoutMs, statements),
2115
+ {
2116
+ cwd: root,
2117
+ systemPathOnly: true,
2118
+ env: {
2119
+ ...target.password === void 0 ? {} : { PGPASSWORD: target.password },
2120
+ PGCONNECT_TIMEOUT: "10"
2121
+ }
2122
+ }
2123
+ );
2124
+ if (result.missing)
2125
+ throw new ToolMissingError("psql", "install the PostgreSQL client");
2126
+ if (result.status !== 0)
2127
+ throw new Error(
2128
+ `psql failed: ${redactConnectionSecrets(result.stderr.trim())}`
2129
+ );
2130
+ return result.stdout;
2131
+ };
2132
+ return {
2133
+ rows: (sql) => {
2134
+ const out = run([
2135
+ `select coalesce(json_agg(t), '[]'::json) from (${sql}) t`
2136
+ ]).trim();
2137
+ return out === "" ? [] : JSON.parse(out);
2138
+ },
2139
+ explain: (sql) => run([
2140
+ explainDoBlock(sql),
2141
+ `select current_setting('${PLAN_SETTING}')`
2142
+ ]).trim()
2143
+ };
2144
+ };
2145
+
2146
+ // src/gate/perfBenchFindings.ts
2147
+ var perfBenchFindings = ({ root, config, runner }, target) => {
2148
+ const session = psqlSession(runner, root, target, config.perf.benchTimeoutMs);
2149
+ return runBench(session, root, readBenchRecord(root), {
2150
+ perf: config.perf,
2151
+ disabled: config.disable
2152
+ }).findings;
2153
+ };
2154
+
2155
+ // src/perf/statementSubject.ts
2156
+ var statementSubject = (window) => `${window.role}: ${window.text.slice(0, 80)}`;
2157
+
2158
+ // src/perf/improvementsIn.ts
2159
+ var improvementsIn = (windows, perf) => windows.reduce((improvements, window) => {
2160
+ if (window.previousMeanMs === null || window.calls < perf.minCalls || window.meanMs > window.previousMeanMs * (1 - perf.regressionPercent / 100))
2161
+ return improvements;
2162
+ return [
2163
+ ...improvements,
2164
+ {
2165
+ subject: statementSubject(window),
2166
+ previousMs: window.previousMeanMs,
2167
+ currentMs: window.meanMs,
2168
+ calls: window.calls,
2169
+ savedMs: (window.previousMeanMs - window.meanMs) * window.calls
2170
+ }
2171
+ ];
2172
+ }, []);
2173
+
2174
+ // src/perf/isRegression.ts
2175
+ var isRegression = (window, perf) => window.previousMeanMs !== null && window.meanMs >= REGRESSION_FLOOR_MS && window.meanMs >= window.previousMeanMs * (1 + perf.regressionPercent / 100) && window.meanMs - window.previousMeanMs >= REGRESSION_FLOOR_MS;
2176
+
2177
+ // src/perf/perfFinding.ts
2178
+ var perfFinding = (code, subject, context, message) => {
2179
+ const partial = {
2180
+ code,
2181
+ severity: code === "BDB901" ? "error" : "warn",
2182
+ path: "postgres",
2183
+ line: 0,
2184
+ message,
2185
+ subject
2186
+ };
2187
+ return { ...partial, fingerprint: fingerprintFinding(partial, context) };
2188
+ };
2189
+
2190
+ // src/perf/statementContext.ts
2191
+ var statementContext = (window) => `${window.role}|${window.queryId}`;
2192
+
2193
+ // src/perf/statementRegressionFinding.ts
2194
+ var statementRegressionFinding = (window, perf) => {
2195
+ if (!isRegression(window, perf) || window.previousMeanMs === null) return null;
2196
+ const percent = Math.round((window.meanMs / window.previousMeanMs - 1) * 100);
2197
+ const sign = percent >= 0 ? "+" : "";
2198
+ return perfFinding(
2199
+ "BDB901",
2200
+ statementSubject(window),
2201
+ statementContext(window),
2202
+ `mean ${window.previousMeanMs.toFixed(2)} ms -> ${window.meanMs.toFixed(2)} ms (${sign}${String(percent)}%) over ${window.calls.toLocaleString("en-US")} calls`
2203
+ );
2204
+ };
2205
+
2206
+ // src/perf/statementSlowFinding.ts
2207
+ var statementSlowFinding = (window, perf) => {
2208
+ if (window.meanMs < perf.slowMs) return null;
2209
+ return perfFinding(
2210
+ "BDB902",
2211
+ statementSubject(window),
2212
+ statementContext(window),
2213
+ `mean ${window.meanMs.toFixed(2)} ms over ${window.calls.toLocaleString("en-US")} calls, threshold ${perf.slowMs.toLocaleString("en-US")} ms`
2214
+ );
2215
+ };
2216
+
2217
+ // src/perf/statementSpillFinding.ts
2218
+ var statementSpillFinding = (window) => {
2219
+ if (window.tempBlksWritten <= 0) return null;
2220
+ return perfFinding(
2221
+ "BDB904",
2222
+ statementSubject(window),
2223
+ statementContext(window),
2224
+ `wrote ${window.tempBlksWritten.toLocaleString("en-US")} temp blocks over ${window.calls.toLocaleString("en-US")} calls: a sort or hash spilled to disk`
2225
+ );
2226
+ };
2227
+
2228
+ // src/perf/statementFindings.ts
2229
+ var statementFindings = (windows, perf, disabled) => windows.filter((window) => window.calls >= perf.minCalls).flatMap((window) => [
2230
+ statementRegressionFinding(window, perf),
2231
+ statementSlowFinding(window, perf),
2232
+ statementSpillFinding(window)
2233
+ ]).filter((finding) => finding !== null).filter((finding) => !isDisabled(finding.code, disabled));
2234
+
2235
+ // src/perf/tableFindings.ts
2236
+ var tableFindings = (windows, perf, disabled) => windows.filter(
2237
+ (window) => window.liveRows >= perf.seqScanRows && window.seqScan > window.idxScan
2238
+ ).map(
2239
+ (window) => perfFinding(
2240
+ "BDB903",
2241
+ window.name,
2242
+ window.name,
2243
+ `${window.seqScan.toLocaleString("en-US")} sequential scans against ${window.idxScan.toLocaleString("en-US")} index scans on ${window.liveRows.toLocaleString("en-US")} live rows`
2244
+ )
2245
+ ).filter((finding) => !isDisabled(finding.code, disabled));
2246
+
2247
+ // src/perf/windowStatements.ts
2248
+ var windowStatements = (previous, current) => {
2249
+ const reset = current.statsReset !== previous.statsReset;
2250
+ return current.statements.map((statement) => {
2251
+ const match = previous.statements.find(
2252
+ (candidate) => candidate.role === statement.role && candidate.queryId === statement.queryId
2253
+ );
2254
+ const fell = match !== void 0 && (statement.calls < match.calls || statement.totalMs < match.totalMs || statement.tempBlksWritten < match.tempBlksWritten);
2255
+ if (reset || match === void 0 || fell) {
2256
+ const { calls: calls2, totalMs: totalMs2, tempBlksWritten } = statement;
2257
+ return {
2258
+ role: statement.role,
2259
+ queryId: statement.queryId,
2260
+ text: statement.text,
2261
+ calls: calls2,
2262
+ totalMs: totalMs2,
2263
+ tempBlksWritten,
2264
+ meanMs: calls2 === 0 ? 0 : totalMs2 / calls2,
2265
+ previousMeanMs: null
2266
+ };
2267
+ }
2268
+ const calls = statement.calls - match.calls;
2269
+ const totalMs = statement.totalMs - match.totalMs;
2270
+ return {
2271
+ role: statement.role,
2272
+ queryId: statement.queryId,
2273
+ text: statement.text,
2274
+ calls,
2275
+ totalMs,
2276
+ tempBlksWritten: statement.tempBlksWritten - match.tempBlksWritten,
2277
+ meanMs: calls === 0 ? 0 : totalMs / calls,
2278
+ previousMeanMs: match.calls === 0 ? null : match.totalMs / match.calls
2279
+ };
2280
+ });
2281
+ };
2282
+
2283
+ // src/perf/windowTables.ts
2284
+ var windowTables = (previous, current) => {
2285
+ const reset = current.statsReset !== previous.statsReset;
2286
+ return current.tables.map((table) => {
2287
+ const match = previous.tables.find(
2288
+ (candidate) => candidate.name === table.name
2289
+ );
2290
+ const fell = match !== void 0 && (table.seqScan < match.seqScan || table.idxScan < match.idxScan);
2291
+ if (reset || match === void 0 || fell) {
2292
+ return {
2293
+ name: table.name,
2294
+ liveRows: table.liveRows,
2295
+ seqScan: table.seqScan,
2296
+ idxScan: table.idxScan
2297
+ };
2298
+ }
2299
+ return {
2300
+ name: table.name,
2301
+ liveRows: table.liveRows,
2302
+ seqScan: table.seqScan - match.seqScan,
2303
+ idxScan: table.idxScan - match.idxScan
2304
+ };
2305
+ });
2306
+ };
2307
+
2308
+ // src/perf/diffSnapshots.ts
2309
+ var diffSnapshots = (previous, current, perf, disabled) => {
2310
+ const statementWindows = windowStatements(previous, current);
2311
+ const eligible = statementWindows.filter(
2312
+ (window) => window.calls >= perf.minCalls
2313
+ );
2314
+ const improvements = improvementsIn(statementWindows, perf);
2315
+ const findings = [
2316
+ ...statementFindings(statementWindows, perf, disabled),
2317
+ ...tableFindings(windowTables(previous, current), perf, disabled)
2318
+ ].sort(compareFindings);
2319
+ return {
2320
+ from: previous.takenAt,
2321
+ to: current.takenAt,
2322
+ improvements,
2323
+ findings,
2324
+ savedMs: improvements.reduce(
2325
+ (sum, improvement) => sum + improvement.savedMs,
2326
+ 0
2327
+ ),
2328
+ lostMs: eligible.filter((window) => isRegression(window, perf)).reduce(
2329
+ (sum, window) => sum + (window.previousMeanMs === null ? 0 : (window.meanMs - window.previousMeanMs) * window.calls),
2330
+ 0
2331
+ )
2332
+ };
2333
+ };
2334
+
2335
+ // src/perf/QUERY_TIMEOUT_MS.ts
2336
+ var QUERY_TIMEOUT_MS = 3e4;
2337
+
2338
+ // src/perf/readPerfSnapshot.ts
2339
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
2340
+ import { join as join12 } from "path";
2341
+
2342
+ // src/perf/PERF_SNAPSHOT_FILENAME.ts
2343
+ var PERF_SNAPSHOT_FILENAME = ".codeality-db-perf.json";
2344
+
2345
+ // src/perf/readPerfSnapshot.ts
2346
+ var readPerfSnapshot = (root) => {
2347
+ const path = join12(root, PERF_SNAPSHOT_FILENAME);
2348
+ if (!existsSync8(path)) {
2349
+ throw new ConfigError(
2350
+ `${PERF_SNAPSHOT_FILENAME} not found; run "codeality-db perf snapshot"`
2351
+ );
2352
+ }
2353
+ const document = JSON.parse(
2354
+ readFileSync8(path, "utf8")
2355
+ );
2356
+ if (document.schemaVersion !== 1) {
2357
+ throw new ConfigError(
2358
+ `${PERF_SNAPSHOT_FILENAME} is not a version 1 snapshot`
2359
+ );
2360
+ }
2361
+ return {
2362
+ schemaVersion: 1,
2363
+ toolVersion: document.toolVersion ?? "",
2364
+ takenAt: document.takenAt ?? "",
2365
+ host: document.host ?? "",
2366
+ statsReset: document.statsReset ?? null,
2367
+ statements: document.statements ?? [],
2368
+ tables: document.tables ?? []
2369
+ };
2370
+ };
2371
+
2372
+ // src/perf/PLATFORM_NOISE.ts
2373
+ var PLATFORM_NOISE = [
2374
+ /^select pg_sleep\(/i,
2375
+ /pg_timezone_names/i,
2376
+ /pg_stat_statements/i,
2377
+ /^WITH -- Recursively get the base types of domains/,
2378
+ /^SELECT wal->>/,
2379
+ /^COPY /i,
2380
+ /^select coalesce\(json_agg\(t\)/i
2381
+ ];
2382
+
2383
+ // src/perf/isPlatformNoise.ts
2384
+ var isPlatformNoise = (text, ignore) => PLATFORM_NOISE.some((pattern) => pattern.test(text)) || ignore.some((fragment) => text.includes(fragment));
2385
+
2386
+ // src/perf/statementStatsSql.ts
2387
+ var statementStatsSql = (roles) => `select r.rolname as role, s.queryid::text as query_id,
2388
+ left(regexp_replace(s.query, '\\s+', ' ', 'g'), 200) as text,
2389
+ s.calls::bigint as calls, s.total_exec_time as total_ms, s.rows::bigint as rows,
2390
+ s.shared_blks_read::bigint as shared_blks_read, s.temp_blks_written::bigint as temp_blks_written
2391
+ from pg_stat_statements s join pg_roles r on r.oid = s.userid
2392
+ where s.toplevel and r.rolname in (${roles.map((role) => `'${role}'`).join(", ")})
2393
+ and s.query ~* '^\\s*(select|with|insert|update|delete|merge|values|table)\\y'
2394
+ and s.query !~* '^\\s*explain'
2395
+ and s.dbid = (select oid from pg_database where datname = current_database())`;
2396
+
2397
+ // src/perf/readStatementStats.ts
2398
+ var readStatementStats = (session, perf) => {
2399
+ const rows = session.rows(statementStatsSql(perf.roles));
2400
+ return rows.map((row) => ({
2401
+ role: String(row["role"]),
2402
+ queryId: String(row["query_id"]),
2403
+ text: String(row["text"]),
2404
+ calls: Number(row["calls"]),
2405
+ totalMs: Number(row["total_ms"]),
2406
+ rows: Number(row["rows"]),
2407
+ sharedBlksRead: Number(row["shared_blks_read"]),
2408
+ tempBlksWritten: Number(row["temp_blks_written"])
2409
+ })).filter((statement) => !isPlatformNoise(statement.text, perf.ignore)).sort(
2410
+ (a, b) => perf.roles.indexOf(a.role) - perf.roles.indexOf(b.role) || a.queryId.localeCompare(b.queryId)
2411
+ );
2412
+ };
2413
+
2414
+ // src/perf/STATS_RESET_SQL.ts
2415
+ var STATS_RESET_SQL = "select stats_reset::text as stats_reset from pg_stat_statements_info()";
2416
+
2417
+ // src/perf/readStatsReset.ts
2418
+ var readStatsReset = (session) => {
2419
+ const rows = session.rows(STATS_RESET_SQL);
2420
+ const value = rows[0]?.["stats_reset"];
2421
+ return typeof value === "string" ? value : null;
2422
+ };
2423
+
2424
+ // src/perf/TABLE_STATS_SQL.ts
2425
+ var TABLE_STATS_SQL = `select schemaname || '.' || relname as name, n_live_tup::bigint as live_rows,
2426
+ seq_scan::bigint as seq_scan, coalesce(idx_scan, 0)::bigint as idx_scan, pg_total_relation_size(relid)::bigint as bytes
2427
+ from pg_stat_user_tables where schemaname = 'public'`;
2428
+
2429
+ // src/perf/readTableStats.ts
2430
+ var readTableStats = (session) => {
2431
+ const rows = session.rows(TABLE_STATS_SQL);
2432
+ return rows.map((row) => ({
2433
+ name: String(row["name"]),
2434
+ liveRows: Number(row["live_rows"]),
2435
+ seqScan: Number(row["seq_scan"]),
2436
+ idxScan: Number(row["idx_scan"]),
2437
+ bytes: Number(row["bytes"])
2438
+ })).sort((a, b) => a.name.localeCompare(b.name));
2439
+ };
2440
+
2441
+ // src/perf/takePerfSnapshot.ts
2442
+ var takePerfSnapshot = (session, perf, host, takenAt) => {
2443
+ const statements = readStatementStats(session, perf);
2444
+ const tables = readTableStats(session);
2445
+ const statsReset = readStatsReset(session);
2446
+ return {
2447
+ schemaVersion: 1,
2448
+ toolVersion: PACKAGE_VERSION,
2449
+ takenAt,
2450
+ host,
2451
+ statsReset,
2452
+ statements,
2453
+ tables
2454
+ };
2455
+ };
2456
+
2457
+ // src/gate/perfDiffFindings.ts
2458
+ var perfDiffFindings = ({ root, config, runner }, target) => {
2459
+ const session = psqlSession(runner, root, target, QUERY_TIMEOUT_MS);
2460
+ const current = takePerfSnapshot(
2461
+ session,
2462
+ config.perf,
2463
+ target.host,
2464
+ (/* @__PURE__ */ new Date()).toISOString()
2465
+ );
2466
+ return diffSnapshots(
2467
+ readPerfSnapshot(root),
2468
+ current,
2469
+ config.perf,
2470
+ config.disable
2471
+ ).findings;
2472
+ };
2473
+
2474
+ // src/postgres/resolvePostgresTarget.ts
2475
+ import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
2476
+ import { join as join13 } from "path";
2477
+ import { env } from "process";
2478
+
2479
+ // src/postgres/DB_PASSWORD_ENV.ts
2480
+ var DB_PASSWORD_ENV = "SUPABASE_DB_PASSWORD";
2481
+
2482
+ // src/postgres/POOLER_URL_FILE.ts
2483
+ var POOLER_URL_FILE = "supabase/.temp/pooler-url";
2484
+
2485
+ // src/postgres/parsedUrl.ts
2486
+ var parsedUrl = (raw, source) => {
2487
+ try {
2488
+ return new URL(raw);
2489
+ } catch {
2490
+ throw new ConfigError(`${source} is not a valid url`);
2491
+ }
2492
+ };
2493
+
2494
+ // src/postgres/refuseTransactionPooler.ts
2495
+ var refuseTransactionPooler = (url, source) => {
2496
+ if (url.port !== "6543" && url.searchParams.get("pgbouncer") !== "true")
2497
+ return;
2498
+ throw new ConfigError(
2499
+ `${source} points at a transaction pooler (port 6543 or pgbouncer=true), where the read-only setting does not hold; use the session pooler (port 5432) or a direct connection`
2500
+ );
2501
+ };
2502
+
2503
+ // src/postgres/takeUrlPassword.ts
2504
+ var takeUrlPassword = (url, source) => {
2505
+ const query = url.searchParams.get("password") ?? void 0;
2506
+ url.searchParams.delete("password");
2507
+ const encoded = url.password;
2508
+ url.password = "";
2509
+ if (!encoded) return query;
2510
+ try {
2511
+ return decodeURIComponent(encoded);
2512
+ } catch {
2513
+ throw new ConfigError(`${source} password is not valid percent-encoding`);
2514
+ }
2515
+ };
2516
+
2517
+ // src/postgres/targetFromUrl.ts
2518
+ var targetFromUrl = (raw, source, fallbackPassword) => {
2519
+ const url = parsedUrl(raw, source);
2520
+ refuseTransactionPooler(url, source);
2521
+ const password = takeUrlPassword(url, source) ?? fallbackPassword;
2522
+ return {
2523
+ url: url.toString(),
2524
+ host: url.hostname,
2525
+ ...password === void 0 ? {} : { password }
2526
+ };
2527
+ };
2528
+
2529
+ // src/postgres/resolvePostgresTarget.ts
2530
+ var resolvePostgresTarget = (root, flags) => {
2531
+ const explicit = flags["db-url"];
2532
+ if (explicit !== void 0) return targetFromUrl(explicit, "--db-url");
2533
+ const poolerPath = join13(root, POOLER_URL_FILE);
2534
+ const password = env[DB_PASSWORD_ENV];
2535
+ if (!existsSync9(poolerPath) || password === void 0 || password === "")
2536
+ return void 0;
2537
+ return targetFromUrl(
2538
+ readFileSync9(poolerPath, "utf8").trim(),
2539
+ POOLER_URL_FILE,
2540
+ password
2541
+ );
2542
+ };
2543
+
2544
+ // src/gate/perfStage.ts
2545
+ var perfStage = (context) => ({
2546
+ name: "perf",
2547
+ run: () => {
2548
+ if (!context.config.perf.inGate) return "not-applicable";
2549
+ const target = resolvePostgresTarget(context.root, {});
2550
+ if (!target)
2551
+ return {
2552
+ skipped: "no linked project with SUPABASE_DB_PASSWORD"
2553
+ };
2554
+ const hasSnapshot = existsSync10(join14(context.root, PERF_SNAPSHOT_FILENAME));
2555
+ const hasRecord = existsSync10(join14(context.root, BENCH_RECORD_FILENAME));
2556
+ if (!hasSnapshot && !hasRecord)
2557
+ return {
2558
+ skipped: 'no perf snapshot and no bench record; run "perf snapshot" or "perf bench --record"'
2559
+ };
2560
+ return [
2561
+ ...hasSnapshot ? perfDiffFindings(context, target) : [],
2562
+ ...hasRecord ? perfBenchFindings(context, target) : []
2563
+ ];
2564
+ }
2565
+ });
2566
+
2567
+ // src/gate/gateStages.ts
2568
+ var gateStages = (context) => {
2569
+ const hasBaseline = existsSync11(join15(context.root, BASELINE_FILENAME));
2570
+ const linked = existsSync11(join15(context.root, "supabase/.temp/project-ref"));
2571
+ return [
2572
+ hasBaseline ? {
2573
+ name: "baseline-check",
2574
+ run: () => classifyFindings(runCheck(context), readBaseline(context.root)).new
2575
+ } : { name: "check", run: () => runCheck(context) },
2576
+ {
2577
+ name: "audit",
2578
+ run: () => context.config.audit.inGate && linked ? runAudit({ ...context, target: { linked: true } }) : "not-applicable"
2579
+ },
2580
+ perfStage(context)
2581
+ ];
2582
+ };
2583
+
2584
+ // src/gate/renderGateReport.ts
2585
+ var renderGateReport = (results) => results.flatMap((result) => [
2586
+ `${result.status.padStart(22)} ${result.name.padEnd(14)}${result.durationSeconds.toFixed(2).padStart(8)}s`,
2587
+ ...result.status === "passed" || !result.detail ? [] : result.detail.split("\n").map((line) => ` ${line}`)
2588
+ ]).join("\n");
2589
+
2590
+ // src/gate/runStage.ts
2591
+ import { performance } from "perf_hooks";
2592
+ var runStage = (stage) => {
2593
+ const started = performance.now();
2594
+ const done = (status, detail) => ({
2595
+ name: stage.name,
2596
+ status,
2597
+ durationSeconds: (performance.now() - started) / 1e3,
2598
+ detail
2599
+ });
2600
+ try {
2601
+ const outcome = stage.run();
2602
+ if (outcome === "not-applicable") return done("skipped-not-applicable", "");
2603
+ if (!Array.isArray(outcome))
2604
+ return done("skipped-not-applicable", outcome.skipped);
2605
+ return outcome.length === 0 ? done("passed", "") : done("findings", renderFindings(outcome));
2606
+ } catch (error) {
2607
+ return done(
2608
+ "failed-to-run",
2609
+ error instanceof Error ? error.message : String(error)
2610
+ );
2611
+ }
2612
+ };
2613
+
2614
+ // src/gate/runGate.ts
2615
+ var runGate = (stages) => stages.map(runStage);
2616
+
2617
+ // src/commands/gateCommand.ts
1327
2618
  var gateCommand = (argv2, io) => {
1328
2619
  try {
1329
2620
  const { values } = parseCommandArgs(argv2, { json: { type: "boolean" } });
2621
+ const config = readConfig(io.root);
2622
+ const notice = legacyConfigNotice(config);
2623
+ if (notice) io.stderr(`${notice}
2624
+ `);
1330
2625
  const results = runGate(
1331
2626
  gateStages({
1332
2627
  root: io.root,
1333
- config: readConfig(io.root),
2628
+ config,
1334
2629
  runner: io.runner
1335
2630
  })
1336
2631
  );
@@ -1343,39 +2638,122 @@ var gateCommand = (argv2, io) => {
1343
2638
  }
1344
2639
  };
1345
2640
 
2641
+ // src/init/adoptionPhase.ts
2642
+ import { existsSync as existsSync12 } from "fs";
2643
+ import { join as join16 } from "path";
2644
+ var adoptionPhase = (root, config) => {
2645
+ if (config.schemaVersion !== 2) return 0;
2646
+ const hasSnapshot = existsSync12(join16(root, PERF_SNAPSHOT_FILENAME));
2647
+ const hasBench = existsSync12(join16(root, BENCH_RECORD_FILENAME));
2648
+ if (config.perf.inGate && (hasSnapshot || hasBench)) return 4;
2649
+ if (hasBench) return 3;
2650
+ if (hasSnapshot) return 2;
2651
+ return 1;
2652
+ };
2653
+
2654
+ // src/init/baselineStep.ts
2655
+ import { existsSync as existsSync13 } from "fs";
2656
+ import { join as join17 } from "path";
2657
+ var baselineStep = (context) => {
2658
+ const findings = runCheck(context);
2659
+ if (findings.length === 0) return "covered";
2660
+ if (!existsSync13(join17(context.root, BASELINE_FILENAME))) return "create";
2661
+ const classified = classifyFindings(findings, readBaseline(context.root));
2662
+ return classified.new.length > 0 ? "update" : "covered";
2663
+ };
2664
+
2665
+ // src/init/adoptionStanding.ts
2666
+ var adoptionStanding = (context) => {
2667
+ const phase = adoptionPhase(context.root, context.config);
2668
+ if (phase === 0) return { phase };
2669
+ let step;
2670
+ try {
2671
+ step = baselineStep(context);
2672
+ } catch (error) {
2673
+ return { phase: 1, next: `make check run: ${error.message}` };
2674
+ }
2675
+ return step === "covered" ? { phase } : { phase: 1, next: `codeality-db baseline ${step}` };
2676
+ };
2677
+
1346
2678
  // src/init/applyInit.ts
1347
2679
  import { mkdirSync, writeFileSync as writeFileSync3 } from "fs";
1348
- import { dirname, join as join9 } from "path";
2680
+ import { dirname, join as join18 } from "path";
1349
2681
  var applyInit = (root, plan) => {
1350
2682
  for (const file of plan) {
1351
2683
  const writes = file.disposition === "create" || file.disposition === "merge";
1352
2684
  if (!writes || file.content === void 0) continue;
1353
- mkdirSync(dirname(join9(root, file.path)), { recursive: true });
1354
- writeFileSync3(join9(root, file.path), file.content);
2685
+ mkdirSync(dirname(join18(root, file.path)), { recursive: true });
2686
+ writeFileSync3(join18(root, file.path), file.content);
1355
2687
  }
1356
2688
  };
1357
2689
 
2690
+ // src/init/planBenchReadme.ts
2691
+ import { existsSync as existsSync14 } from "fs";
2692
+ import { join as join19 } from "path";
2693
+
2694
+ // src/init/BENCH_README.ts
2695
+ var BENCH_README = `# Bench queries
2696
+
2697
+ Each \`.sql\` file in this directory holds one statement to benchmark. Write
2698
+ literal values inline; the session that runs them has no way to bind
2699
+ parameters.
2700
+
2701
+ An optional first line \`-- runs: N\` overrides how many times the statement
2702
+ runs before the median is recorded, in place of \`perf.benchRuns\` in
2703
+ codeality-db.json.
2704
+
2705
+ A file holding more than one statement is refused. The statement runs
2706
+ inside a read-only transaction it cannot leave, and whatever it did is
2707
+ rolled back, so a statement that writes is refused or undone instead of
2708
+ touching the database.
2709
+
2710
+ Run \`codeality-db perf bench --record\` once to write the reference numbers
2711
+ to ${BENCH_RECORD_FILENAME}. Run \`codeality-db perf bench\` afterwards to
2712
+ compare a new run against that reference.
2713
+ `;
2714
+
2715
+ // src/init/planBenchReadme.ts
2716
+ var planBenchReadme = (root) => {
2717
+ const benchDir = (() => {
2718
+ try {
2719
+ return readConfig(root).perf.benchDir;
2720
+ } catch {
2721
+ return PERF_DEFAULTS.benchDir;
2722
+ }
2723
+ })();
2724
+ const relative4 = `${benchDir}/README.md`;
2725
+ if (existsSync14(join19(root, relative4))) {
2726
+ return { path: relative4, disposition: "unchanged", detail: "present" };
2727
+ }
2728
+ return {
2729
+ path: relative4,
2730
+ disposition: "create",
2731
+ detail: "explains the bench convention",
2732
+ content: BENCH_README
2733
+ };
2734
+ };
2735
+
1358
2736
  // src/init/planConfigFile.ts
1359
- import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
1360
- import { join as join12 } from "path";
2737
+ import { existsSync as existsSync17, readFileSync as readFileSync11 } from "fs";
2738
+ import { join as join23 } from "path";
1361
2739
 
1362
2740
  // src/config/detectStacks.ts
1363
- import { existsSync as existsSync8 } from "fs";
1364
- import { join as join11 } from "path";
2741
+ import { existsSync as existsSync16 } from "fs";
2742
+ import { join as join22 } from "path";
1365
2743
 
1366
2744
  // src/config/findSqliteFiles.ts
1367
- import { readdirSync as readdirSync2 } from "fs";
1368
- import { join as join10, relative as relative2 } from "path";
2745
+ import { readdirSync as readdirSync4 } from "fs";
2746
+ import { join as join20, relative as relative3 } from "path";
1369
2747
  var findSqliteFiles = (root) => {
1370
2748
  const skipped = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
1371
2749
  const found = [];
1372
2750
  const walk = (directory, depth) => {
1373
- for (const entry of readdirSync2(directory, { withFileTypes: true })) {
2751
+ for (const entry of readdirSync4(directory, { withFileTypes: true })) {
1374
2752
  if (skipped.has(entry.name) || entry.name.startsWith(".")) continue;
1375
- const path = join10(directory, entry.name);
2753
+ const path = join20(directory, entry.name);
1376
2754
  if (entry.isDirectory() && depth < 2) walk(path, depth + 1);
1377
2755
  else if (entry.isFile() && /\.(?:db|sqlite|sqlite3)$/.test(entry.name)) {
1378
- found.push(relative2(root, path).replaceAll("\\", "/"));
2756
+ found.push(relative3(root, path).replaceAll("\\", "/"));
1379
2757
  }
1380
2758
  }
1381
2759
  };
@@ -1383,49 +2761,102 @@ var findSqliteFiles = (root) => {
1383
2761
  return found.sort();
1384
2762
  };
1385
2763
 
1386
- // src/config/isDirectory.ts
1387
- import { existsSync as existsSync7, statSync } from "fs";
1388
- var isDirectory = (path) => existsSync7(path) && statSync(path).isDirectory();
2764
+ // src/config/hasPostgrestSources.ts
2765
+ var hasPostgrestSources = (root, name) => sourceFilesUnder(root, [name]).length > 0;
2766
+
2767
+ // src/config/hasSupabaseJsDependency.ts
2768
+ import { existsSync as existsSync15, readFileSync as readFileSync10 } from "fs";
2769
+ import { join as join21 } from "path";
2770
+ var hasSupabaseJsDependency = (root) => {
2771
+ const manifestPath = join21(root, "package.json");
2772
+ if (!existsSync15(manifestPath)) return false;
2773
+ const manifest = JSON.parse(
2774
+ readFileSync10(manifestPath, "utf8")
2775
+ );
2776
+ const dependencies = { ...manifest.dependencies, ...manifest.devDependencies };
2777
+ return "@supabase/supabase-js" in dependencies;
2778
+ };
1389
2779
 
1390
2780
  // src/config/detectStacks.ts
1391
2781
  var detectStacks = (root) => {
1392
2782
  const sqliteFiles = findSqliteFiles(root);
1393
2783
  const hasDrizzle = ["ts", "js", "mjs"].some(
1394
- (ext) => existsSync8(join11(root, `drizzle.config.${ext}`))
2784
+ (ext) => existsSync16(join22(root, `drizzle.config.${ext}`))
1395
2785
  );
1396
2786
  const drizzleRoots = ["src", "server", "app", "lib", "db"].filter(
1397
- (name) => isDirectory(join11(root, name))
2787
+ (name) => isDirectory(join22(root, name))
2788
+ );
2789
+ const postgrestRoots = ["src", "app", "supabase/functions"].filter(
2790
+ (name) => isDirectory(join22(root, name)) && hasPostgrestSources(root, name)
1398
2791
  );
1399
2792
  return {
1400
- ...isDirectory(join11(root, "supabase/migrations")) ? { supabase: { migrations: "supabase/migrations" } } : {},
1401
- ...existsSync8(join11(root, "prisma/schema.prisma")) ? { prisma: { schema: "prisma/schema.prisma" } } : {},
2793
+ ...isDirectory(join22(root, "supabase/migrations")) ? { supabase: { migrations: "supabase/migrations" } } : {},
2794
+ ...existsSync16(join22(root, "prisma/schema.prisma")) ? { prisma: { schema: "prisma/schema.prisma" } } : {},
1402
2795
  ...hasDrizzle ? { drizzle: { roots: drizzleRoots, objectNames: ["db", "tx"] } } : {},
1403
- ...sqliteFiles.length > 0 ? { sqlite: { files: sqliteFiles } } : {}
2796
+ ...sqliteFiles.length > 0 ? { sqlite: { files: sqliteFiles } } : {},
2797
+ ...hasSupabaseJsDependency(root) && postgrestRoots.length > 0 ? { postgrest: { roots: postgrestRoots } } : {}
2798
+ };
2799
+ };
2800
+
2801
+ // src/init/PERF_INIT_SECTION.ts
2802
+ var PERF_INIT_SECTION = { ...PERF_DEFAULTS, inGate: false };
2803
+
2804
+ // src/init/upgradedConfigDocument.ts
2805
+ var upgradedConfigDocument = (raw, detected) => {
2806
+ const disable = raw["disable"];
2807
+ return {
2808
+ ...raw,
2809
+ schemaVersion: 2,
2810
+ ...Array.isArray(disable) ? {
2811
+ disable: disable.map(
2812
+ (entry) => typeof entry === "string" ? { code: entry, reason: LEGACY_DISABLE_REASON } : entry
2813
+ )
2814
+ } : {},
2815
+ ...raw["postgrest"] === void 0 && detected.postgrest !== void 0 ? { postgrest: detected.postgrest } : {},
2816
+ ...raw["perf"] === void 0 ? { perf: PERF_INIT_SECTION } : {}
1404
2817
  };
1405
2818
  };
1406
2819
 
1407
2820
  // src/init/planConfigFile.ts
1408
2821
  var planConfigFile = (root, force) => {
1409
- const content = `${JSON.stringify({ schemaVersion: 1, ...detectStacks(root) }, null, 2)}
2822
+ const path = join23(root, CONFIG_FILENAME);
2823
+ const freshContent = `${JSON.stringify(
2824
+ { schemaVersion: 2, ...detectStacks(root), perf: PERF_INIT_SECTION },
2825
+ null,
2826
+ 2
2827
+ )}
1410
2828
  `;
1411
- const path = join12(root, CONFIG_FILENAME);
1412
- if (!existsSync9(path)) {
2829
+ if (!existsSync17(path)) {
1413
2830
  return {
1414
2831
  path: CONFIG_FILENAME,
1415
2832
  disposition: "create",
1416
2833
  detail: "from the detected stacks",
1417
- content
2834
+ content: freshContent
1418
2835
  };
1419
2836
  }
1420
2837
  try {
1421
- configFromDocument(JSON.parse(readFileSync5(path, "utf8")));
2838
+ const raw = JSON.parse(readFileSync11(path, "utf8"));
2839
+ const config = configFromDocument(raw);
2840
+ if (config.schemaVersion === 1) {
2841
+ return {
2842
+ path: CONFIG_FILENAME,
2843
+ disposition: "merge",
2844
+ detail: "upgraded to schemaVersion 2",
2845
+ content: `${JSON.stringify(
2846
+ upgradedConfigDocument(raw, detectStacks(root)),
2847
+ null,
2848
+ 2
2849
+ )}
2850
+ `
2851
+ };
2852
+ }
1422
2853
  return { path: CONFIG_FILENAME, disposition: "unchanged", detail: "valid" };
1423
2854
  } catch (error) {
1424
2855
  return force ? {
1425
2856
  path: CONFIG_FILENAME,
1426
2857
  disposition: "merge",
1427
2858
  detail: "replaced (--force)",
1428
- content
2859
+ content: freshContent
1429
2860
  } : {
1430
2861
  path: CONFIG_FILENAME,
1431
2862
  disposition: "conflict",
@@ -1435,23 +2866,23 @@ var planConfigFile = (root, force) => {
1435
2866
  };
1436
2867
 
1437
2868
  // src/init/planPackageScript.ts
1438
- import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
1439
- import { join as join13 } from "path";
2869
+ import { existsSync as existsSync18, readFileSync as readFileSync12 } from "fs";
2870
+ import { join as join24 } from "path";
1440
2871
 
1441
2872
  // src/init/PACKAGE_MANIFEST.ts
1442
2873
  var PACKAGE_MANIFEST = "package.json";
1443
2874
 
1444
2875
  // src/init/planPackageScript.ts
1445
2876
  var planPackageScript = (root, force) => {
1446
- const path = join13(root, PACKAGE_MANIFEST);
1447
- if (!existsSync10(path)) {
2877
+ const path = join24(root, PACKAGE_MANIFEST);
2878
+ if (!existsSync18(path)) {
1448
2879
  return {
1449
2880
  path: PACKAGE_MANIFEST,
1450
2881
  disposition: "unchanged",
1451
2882
  detail: "no package.json"
1452
2883
  };
1453
2884
  }
1454
- const manifest = JSON.parse(readFileSync6(path, "utf8"));
2885
+ const manifest = JSON.parse(readFileSync12(path, "utf8"));
1455
2886
  const current = manifest.scripts?.["db:gate"];
1456
2887
  if (current === "codeality-db gate") {
1457
2888
  return {
@@ -1479,26 +2910,47 @@ var planPackageScript = (root, force) => {
1479
2910
  };
1480
2911
 
1481
2912
  // src/init/planWorkflow.ts
1482
- import { existsSync as existsSync11, readFileSync as readFileSync7 } from "fs";
1483
- import { join as join14 } from "path";
2913
+ import { existsSync as existsSync19, readFileSync as readFileSync13 } from "fs";
2914
+ import { join as join25 } from "path";
2915
+
2916
+ // src/init/sha256Of.ts
2917
+ import { createHash as createHash2 } from "crypto";
2918
+ var sha256Of = (content) => createHash2("sha256").update(content).digest("hex");
2919
+
2920
+ // src/init/SHIPPED_WORKFLOW_HASHES.ts
2921
+ var SHIPPED_WORKFLOW_HASHES = {
2922
+ "4845c1f1cd3c179701d4a143cb34400c8a6225b73e6e86418408e8f3ae1581c0": "0.1.0"
2923
+ };
2924
+
2925
+ // src/init/planWorkflow.ts
1484
2926
  var planWorkflow = (root, force) => {
1485
- const relative3 = ".github/workflows/db-quality.yml";
1486
- const content = readFileSync7(assetPath("db-quality.yml"), "utf8");
1487
- const path = join14(root, relative3);
1488
- if (!existsSync11(path)) {
2927
+ const relative4 = ".github/workflows/db-quality.yml";
2928
+ const content = readFileSync13(assetPath("db-quality.yml"), "utf8");
2929
+ const path = join25(root, relative4);
2930
+ if (!existsSync19(path)) {
1489
2931
  const detail = "runs codeality-db gate on push and pull request";
1490
- return { path: relative3, disposition: "create", detail, content };
2932
+ return { path: relative4, disposition: "create", detail, content };
1491
2933
  }
1492
- if (readFileSync7(path, "utf8") === content) {
1493
- return { path: relative3, disposition: "unchanged", detail: "identical" };
2934
+ const existing = readFileSync13(path, "utf8");
2935
+ if (existing === content) {
2936
+ return { path: relative4, disposition: "unchanged", detail: "identical" };
2937
+ }
2938
+ const shippedVersion = SHIPPED_WORKFLOW_HASHES[sha256Of(existing)];
2939
+ if (shippedVersion !== void 0) {
2940
+ return {
2941
+ path: relative4,
2942
+ disposition: "merge",
2943
+ detail: `upgraded from the ${shippedVersion} workflow`,
2944
+ content
2945
+ };
1494
2946
  }
1495
2947
  return force ? {
1496
- path: relative3,
2948
+ path: relative4,
1497
2949
  disposition: "merge",
1498
2950
  detail: "replaced (--force)",
1499
2951
  content
1500
2952
  } : {
1501
- path: relative3,
2953
+ path: relative4,
1502
2954
  disposition: "conflict",
1503
2955
  detail: "differs from the shipped workflow"
1504
2956
  };
@@ -1508,9 +2960,51 @@ var planWorkflow = (root, force) => {
1508
2960
  var planInit = (root, force) => [
1509
2961
  planConfigFile(root, force),
1510
2962
  planPackageScript(root, force),
1511
- planWorkflow(root, force)
2963
+ planWorkflow(root, force),
2964
+ planBenchReadme(root)
2965
+ ];
2966
+
2967
+ // src/init/ADOPTION_LADDER.ts
2968
+ var ADOPTION_LADDER = [
2969
+ { phase: 0, does: "no configuration", next: "codeality-db init --apply" },
2970
+ {
2971
+ phase: 1,
2972
+ does: "strict check and PostgREST rules, baseline covers what exists",
2973
+ next: "codeality-db perf snapshot"
2974
+ },
2975
+ {
2976
+ phase: 2,
2977
+ does: "perf snapshot recorded",
2978
+ next: "codeality-db perf bench --record"
2979
+ },
2980
+ {
2981
+ phase: 3,
2982
+ does: "bench reference recorded",
2983
+ next: "set perf.inGate to true in codeality-db.json"
2984
+ },
2985
+ {
2986
+ phase: 4,
2987
+ does: "the gate measures performance",
2988
+ next: "nothing: the gate measures"
2989
+ }
1512
2990
  ];
1513
2991
 
2992
+ // src/init/renderAdoptionPhase.ts
2993
+ var renderAdoptionPhase = ({
2994
+ phase,
2995
+ next
2996
+ }) => {
2997
+ const rungs = ADOPTION_LADDER.map(
2998
+ (rung) => `${rung.phase === phase ? "*" : " "} ${String(rung.phase)} ${rung.does}`
2999
+ );
3000
+ const current = ADOPTION_LADDER.find((rung) => rung.phase === phase);
3001
+ return [
3002
+ `adoption phase ${String(phase)} of 4`,
3003
+ ...rungs,
3004
+ `next: ${next ?? current?.next ?? ""}`
3005
+ ].join("\n");
3006
+ };
3007
+
1514
3008
  // src/init/renderInitPlan.ts
1515
3009
  var renderInitPlan = (plan) => plan.map(
1516
3010
  (file) => `${file.disposition.padEnd(9)} ${file.path} ${file.detail}`
@@ -1528,15 +3022,258 @@ var initCommand = (argv2, io) => {
1528
3022
  const plan = planInit(io.root, force);
1529
3023
  io.stdout(`${renderInitPlan(plan)}
1530
3024
  `);
3025
+ let exitCode = ExitCode.OK;
1531
3026
  if (values["check"] === true) {
1532
- return plan.every((file) => file.disposition === "unchanged") ? ExitCode.OK : ExitCode.FINDINGS;
1533
- }
1534
- if (values["apply"] !== true && !force) return ExitCode.OK;
1535
- if (plan.some((file) => file.disposition === "conflict")) {
1536
- throw new ConfigError("conflicts remain; resolve them or pass --force");
3027
+ exitCode = plan.every((file) => file.disposition === "unchanged") ? ExitCode.OK : ExitCode.FINDINGS;
3028
+ } else if (values["apply"] === true || force) {
3029
+ if (plan.some((file) => file.disposition === "conflict")) {
3030
+ throw new ConfigError("conflicts remain; resolve them or pass --force");
3031
+ }
3032
+ applyInit(io.root, plan);
1537
3033
  }
1538
- applyInit(io.root, plan);
3034
+ const config = (() => {
3035
+ try {
3036
+ return readConfig(io.root);
3037
+ } catch {
3038
+ return void 0;
3039
+ }
3040
+ })();
3041
+ const standing = config ? adoptionStanding({ root: io.root, config, runner: io.runner }) : { phase: 0 };
3042
+ io.stdout(`${renderAdoptionPhase(standing)}
3043
+ `);
3044
+ return exitCode;
3045
+ } catch (error) {
3046
+ return reportCommandError(error, io.stderr);
3047
+ }
3048
+ };
3049
+
3050
+ // src/bench/renderBench.ts
3051
+ var renderBench = (result, recorded) => {
3052
+ const entries = Object.entries(result.entries);
3053
+ const width = Math.max(0, ...entries.map(([file]) => file.length)) + 2;
3054
+ const runs = new Set(entries.map(([, entry]) => entry.runs));
3055
+ const perFile = runs.size > 1;
3056
+ const lines = entries.map(([file, entry]) => {
3057
+ const recordedEntry = recorded?.entries[file];
3058
+ const record = recordedEntry ? `(record ${recordedEntry.medianMs.toFixed(2)} ms)` : "(no record)";
3059
+ const count = perFile ? ` ${String(entry.runs)} runs` : "";
3060
+ return ` ${file.padEnd(width)}${entry.medianMs.toFixed(2)} ms ${record}${count}`;
3061
+ });
3062
+ const improvementLines = result.improvements.length === 0 ? [] : [
3063
+ "improvements",
3064
+ ...result.improvements.map((improvement) => {
3065
+ const percent = Math.round(
3066
+ (improvement.currentMs / improvement.previousMs - 1) * 100
3067
+ );
3068
+ return ` ${String(percent)}% ${improvement.previousMs.toFixed(2)} ms -> ${improvement.currentMs.toFixed(2)} ms ${improvement.subject}`;
3069
+ })
3070
+ ];
3071
+ return [
3072
+ perFile ? "bench (runs per file)" : `bench (${String([...runs][0] ?? 0)} runs each)`,
3073
+ ...lines,
3074
+ ...improvementLines,
3075
+ renderFindings(result.findings)
3076
+ ].join("\n");
3077
+ };
3078
+
3079
+ // src/bench/renderBenchJson.ts
3080
+ var renderBenchJson = (result, recorded) => JSON.stringify(
3081
+ {
3082
+ schemaVersion: 1,
3083
+ entries: result.entries,
3084
+ recorded: recorded?.entries ?? null,
3085
+ improvements: result.improvements,
3086
+ findings: result.findings
3087
+ },
3088
+ null,
3089
+ 2
3090
+ );
3091
+
3092
+ // src/bench/writeBenchRecord.ts
3093
+ import { renameSync as renameSync2, writeFileSync as writeFileSync4 } from "fs";
3094
+ import { join as join26 } from "path";
3095
+ var writeBenchRecord = (root, record) => {
3096
+ const path = join26(root, BENCH_RECORD_FILENAME);
3097
+ writeFileSync4(`${path}.tmp`, `${JSON.stringify(record, null, 2)}
3098
+ `);
3099
+ renameSync2(`${path}.tmp`, path);
3100
+ };
3101
+
3102
+ // src/commands/perfBenchAction.ts
3103
+ var perfBenchAction = ({
3104
+ io,
3105
+ config,
3106
+ target,
3107
+ session,
3108
+ json,
3109
+ record
3110
+ }) => {
3111
+ const recorded = record ? void 0 : readBenchRecord(io.root);
3112
+ const result = runBench(session, io.root, recorded, {
3113
+ perf: config.perf,
3114
+ disabled: config.disable
3115
+ });
3116
+ if (record) {
3117
+ writeBenchRecord(io.root, {
3118
+ schemaVersion: 1,
3119
+ toolVersion: PACKAGE_VERSION,
3120
+ takenAt: (/* @__PURE__ */ new Date()).toISOString(),
3121
+ host: target.host,
3122
+ entries: result.entries
3123
+ });
3124
+ io.stdout(
3125
+ `recorded ${String(Object.keys(result.entries).length)} bench queries in ${BENCH_RECORD_FILENAME}
3126
+ `
3127
+ );
1539
3128
  return ExitCode.OK;
3129
+ }
3130
+ io.stdout(
3131
+ `${json ? renderBenchJson(result, recorded) : renderBench(result, recorded)}
3132
+ `
3133
+ );
3134
+ return result.findings.length > 0 ? ExitCode.FINDINGS : ExitCode.OK;
3135
+ };
3136
+
3137
+ // src/perf/renderImprovements.ts
3138
+ var renderImprovements = (improvements) => improvements.length === 0 ? [] : [
3139
+ "improvements",
3140
+ ...improvements.map((improvement) => {
3141
+ const percent = Math.round(
3142
+ (improvement.currentMs / improvement.previousMs - 1) * 100
3143
+ );
3144
+ return ` ${String(percent)}% ${improvement.previousMs.toFixed(2)} ms -> ${improvement.currentMs.toFixed(2)} ms x${improvement.calls.toLocaleString("en-US")} ${improvement.subject}`;
3145
+ })
3146
+ ];
3147
+
3148
+ // src/perf/renderPerfDiff.ts
3149
+ var renderPerfDiff = (diff) => [
3150
+ `perf diff: ${diff.from} -> ${diff.to}`,
3151
+ ...renderImprovements(diff.improvements),
3152
+ ...diff.improvements.length > 0 || diff.lostMs > 0 ? [
3153
+ `saved ${diff.savedMs.toLocaleString("en-US")} ms against the previous means; regressions cost ${diff.lostMs.toLocaleString("en-US")} ms`
3154
+ ] : [],
3155
+ ...diff.findings.map(
3156
+ (finding) => `${finding.path}:${String(finding.line)}: ${finding.code} ${finding.message} (${finding.subject})`
3157
+ ),
3158
+ `${diff.findings.length.toLocaleString("en-US")} findings`
3159
+ ].join("\n");
3160
+
3161
+ // src/perf/renderPerfDiffJson.ts
3162
+ var renderPerfDiffJson = (diff) => JSON.stringify(
3163
+ {
3164
+ schemaVersion: 1,
3165
+ from: diff.from,
3166
+ to: diff.to,
3167
+ savedMs: diff.savedMs,
3168
+ lostMs: diff.lostMs,
3169
+ improvements: diff.improvements,
3170
+ findings: diff.findings
3171
+ },
3172
+ null,
3173
+ 2
3174
+ );
3175
+
3176
+ // src/commands/perfDiffAction.ts
3177
+ var perfDiffAction = ({
3178
+ io,
3179
+ config,
3180
+ target,
3181
+ session,
3182
+ json
3183
+ }) => {
3184
+ const previous = readPerfSnapshot(io.root);
3185
+ const current = takePerfSnapshot(
3186
+ session,
3187
+ config.perf,
3188
+ target.host,
3189
+ (/* @__PURE__ */ new Date()).toISOString()
3190
+ );
3191
+ const diff = diffSnapshots(previous, current, config.perf, config.disable);
3192
+ io.stdout(`${json ? renderPerfDiffJson(diff) : renderPerfDiff(diff)}
3193
+ `);
3194
+ return diff.findings.length > 0 ? ExitCode.FINDINGS : ExitCode.OK;
3195
+ };
3196
+
3197
+ // src/perf/writePerfSnapshot.ts
3198
+ import { renameSync as renameSync3, writeFileSync as writeFileSync5 } from "fs";
3199
+ import { join as join27 } from "path";
3200
+ var writePerfSnapshot = (root, snapshot) => {
3201
+ const path = join27(root, PERF_SNAPSHOT_FILENAME);
3202
+ writeFileSync5(`${path}.tmp`, `${JSON.stringify(snapshot, null, 2)}
3203
+ `);
3204
+ renameSync3(`${path}.tmp`, path);
3205
+ };
3206
+
3207
+ // src/commands/perfSnapshotAction.ts
3208
+ var perfSnapshotAction = ({
3209
+ io,
3210
+ config,
3211
+ target,
3212
+ session
3213
+ }) => {
3214
+ const snapshot = takePerfSnapshot(
3215
+ session,
3216
+ config.perf,
3217
+ target.host,
3218
+ (/* @__PURE__ */ new Date()).toISOString()
3219
+ );
3220
+ writePerfSnapshot(io.root, snapshot);
3221
+ io.stdout(
3222
+ `recorded ${String(snapshot.statements.length)} statements and ${String(snapshot.tables.length)} tables from ${target.host} in ${PERF_SNAPSHOT_FILENAME}
3223
+ `
3224
+ );
3225
+ return ExitCode.OK;
3226
+ };
3227
+
3228
+ // src/perf/PERF_USAGE.ts
3229
+ var PERF_USAGE = "usage: codeality-db perf snapshot|diff|bench [--db-url <url>] [--json] [--record]";
3230
+
3231
+ // src/perf/perfActionFrom.ts
3232
+ var perfActionFrom = (positionals) => {
3233
+ const action = positionals[0];
3234
+ if (action === "snapshot" || action === "diff" || action === "bench")
3235
+ return action;
3236
+ throw new ConfigError(PERF_USAGE);
3237
+ };
3238
+
3239
+ // src/postgres/requirePostgresTarget.ts
3240
+ var requirePostgresTarget = (root, flags) => {
3241
+ const target = resolvePostgresTarget(root, flags);
3242
+ if (target) return target;
3243
+ throw new ConfigError(
3244
+ `perf needs --db-url, or a linked project with ${DB_PASSWORD_ENV} set (the same variable the Supabase CLI reads)`
3245
+ );
3246
+ };
3247
+
3248
+ // src/commands/perfCommand.ts
3249
+ var perfCommand = (argv2, io) => {
3250
+ try {
3251
+ const { values, positionals } = parseCommandArgs(argv2, {
3252
+ json: { type: "boolean" },
3253
+ record: { type: "boolean" },
3254
+ "db-url": { type: "string" }
3255
+ });
3256
+ const action = perfActionFrom(positionals);
3257
+ const config = readConfig(io.root);
3258
+ const notice = legacyConfigNotice(config);
3259
+ if (notice) io.stderr(`${notice}
3260
+ `);
3261
+ const target = requirePostgresTarget(io.root, {
3262
+ "db-url": values["db-url"]
3263
+ });
3264
+ const timeout = action === "bench" ? config.perf.benchTimeoutMs : QUERY_TIMEOUT_MS;
3265
+ const session = psqlSession(io.runner, io.root, target, timeout);
3266
+ const actionIo = {
3267
+ io,
3268
+ config,
3269
+ target,
3270
+ session,
3271
+ json: values["json"] === true,
3272
+ record: values["record"] === true
3273
+ };
3274
+ if (action === "snapshot") return perfSnapshotAction(actionIo);
3275
+ if (action === "diff") return perfDiffAction(actionIo);
3276
+ return perfBenchAction(actionIo);
1540
3277
  } catch (error) {
1541
3278
  return reportCommandError(error, io.stderr);
1542
3279
  }
@@ -1548,7 +3285,8 @@ var COMMANDS = {
1548
3285
  check: checkCommand,
1549
3286
  audit: auditCommand,
1550
3287
  gate: gateCommand,
1551
- baseline: baselineCommand
3288
+ baseline: baselineCommand,
3289
+ perf: perfCommand
1552
3290
  };
1553
3291
 
1554
3292
  // src/cli/USAGE.ts
@@ -1559,6 +3297,7 @@ var USAGE = `usage: codeality-db [--project <dir>] <command> [options]
1559
3297
  audit --linked|--db-url <url> [--json] findings from the live database
1560
3298
  gate [--json] check (or baseline check) and the linked audit, one exit code
1561
3299
  baseline create|update|check [--check-stale] record, refresh or enforce the debt the project carries
3300
+ perf snapshot|diff|bench [--db-url <url>] [--json] [--record] record, compare and benchmark the live database
1562
3301
 
1563
3302
  exit codes: 0 passed, 1 findings, 2 invalid usage or configuration, 3 required tool missing or unusable
1564
3303
  `;
@@ -1591,7 +3330,7 @@ var runCli = (argv2, io) => {
1591
3330
 
1592
3331
  // src/tools/spawnRunner.ts
1593
3332
  import { spawnSync } from "child_process";
1594
- import { delimiter, join as join15 } from "path";
3333
+ import { delimiter, join as join28 } from "path";
1595
3334
  import { env as processEnv } from "process";
1596
3335
 
1597
3336
  // src/tools/packageBinPath.ts
@@ -1600,8 +3339,8 @@ var packageBinPath = () => fileURLToPath2(new URL("../node_modules/.bin", import
1600
3339
 
1601
3340
  // src/tools/spawnRunner.ts
1602
3341
  var spawnRunner = (command, args, options) => {
1603
- const path = [
1604
- join15(options.cwd, "node_modules/.bin"),
3342
+ const path = options.systemPathOnly ? processEnv["PATH"] ?? "" : [
3343
+ join28(options.cwd, "node_modules/.bin"),
1605
3344
  packageBinPath(),
1606
3345
  processEnv["PATH"] ?? ""
1607
3346
  ].join(delimiter);