@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/README.md +247 -25
- package/assets/db-quality.yml +2 -0
- package/dist/cli.js +1894 -155
- package/dist/cli.js.map +1 -1
- package/package.json +19 -16
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.
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
593
|
+
schemaVersion,
|
|
485
594
|
...stackSectionsFrom(raw),
|
|
486
595
|
audit: auditSectionFrom(configSection(raw, "audit")),
|
|
487
|
-
|
|
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
|
|
524
|
-
io.stderr(`${
|
|
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
|
|
544
|
-
import { join as
|
|
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: "
|
|
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
|
|
1102
|
-
import { join as
|
|
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) =>
|
|
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
|
-
|
|
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 =
|
|
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" &&
|
|
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
|
|
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
|
|
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
|
|
1279
|
-
import { join as
|
|
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/
|
|
1296
|
-
|
|
1297
|
-
|
|
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/
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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/
|
|
1324
|
-
var
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
|
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
|
|
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(
|
|
1354
|
-
writeFileSync3(
|
|
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
|
|
1360
|
-
import { join as
|
|
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
|
|
1364
|
-
import { join as
|
|
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
|
|
1368
|
-
import { join as
|
|
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
|
|
2751
|
+
for (const entry of readdirSync4(directory, { withFileTypes: true })) {
|
|
1374
2752
|
if (skipped.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
1375
|
-
const path =
|
|
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(
|
|
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/
|
|
1387
|
-
|
|
1388
|
-
|
|
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) =>
|
|
2784
|
+
(ext) => existsSync16(join22(root, `drizzle.config.${ext}`))
|
|
1395
2785
|
);
|
|
1396
2786
|
const drizzleRoots = ["src", "server", "app", "lib", "db"].filter(
|
|
1397
|
-
(name) => isDirectory(
|
|
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(
|
|
1401
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1439
|
-
import { join as
|
|
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 =
|
|
1447
|
-
if (!
|
|
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(
|
|
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
|
|
1483
|
-
import { join as
|
|
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
|
|
1486
|
-
const content =
|
|
1487
|
-
const path =
|
|
1488
|
-
if (!
|
|
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:
|
|
2932
|
+
return { path: relative4, disposition: "create", detail, content };
|
|
1491
2933
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
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:
|
|
2948
|
+
path: relative4,
|
|
1497
2949
|
disposition: "merge",
|
|
1498
2950
|
detail: "replaced (--force)",
|
|
1499
2951
|
content
|
|
1500
2952
|
} : {
|
|
1501
|
-
path:
|
|
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
|
-
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|