@sdt-tools/cli 0.2.0 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/advise-tests-6DRSZMBL.js +87 -0
- package/dist/advise-tests-6DRSZMBL.js.map +1 -0
- package/dist/ai-G4MJWHTM.js +89 -0
- package/dist/ai-G4MJWHTM.js.map +1 -0
- package/dist/anonymize-QR6JGXA7.js +123 -0
- package/dist/anonymize-QR6JGXA7.js.map +1 -0
- package/dist/approval-YVHYTV53.js +73 -0
- package/dist/approval-YVHYTV53.js.map +1 -0
- package/dist/approval-chain-54KKJZS3.js +120 -0
- package/dist/approval-chain-54KKJZS3.js.map +1 -0
- package/dist/audit-log-QZFH7LUX.js +159 -0
- package/dist/audit-log-QZFH7LUX.js.map +1 -0
- package/dist/backlog-V2YUIQDL.js +76 -0
- package/dist/backlog-V2YUIQDL.js.map +1 -0
- package/dist/bisect-GEVYAVL5.js +111 -0
- package/dist/bisect-GEVYAVL5.js.map +1 -0
- package/dist/bookmarks-57LKS7P6.js +107 -0
- package/dist/bookmarks-57LKS7P6.js.map +1 -0
- package/dist/branch-W2MGMPSH.js +88 -0
- package/dist/branch-W2MGMPSH.js.map +1 -0
- package/dist/build-VNIQFKSP.js +23 -0
- package/dist/build-VNIQFKSP.js.map +1 -0
- package/dist/catalog-JLB5VCEV.js +137 -0
- package/dist/catalog-JLB5VCEV.js.map +1 -0
- package/dist/changelog-M7XGDYSY.js +220 -0
- package/dist/changelog-M7XGDYSY.js.map +1 -0
- package/dist/chunk-DGUM43GV.js +11 -0
- package/dist/chunk-DGUM43GV.js.map +1 -0
- package/dist/chunk-EWXM4KJN.js +25 -0
- package/dist/chunk-EWXM4KJN.js.map +1 -0
- package/dist/chunk-JP2EZLR5.js +50 -0
- package/dist/chunk-JP2EZLR5.js.map +1 -0
- package/dist/chunk-VM2H4LAO.js +15 -0
- package/dist/chunk-VM2H4LAO.js.map +1 -0
- package/dist/chunk-ZWY4ZRHL.js +44 -0
- package/dist/chunk-ZWY4ZRHL.js.map +1 -0
- package/dist/cli.js +511 -19014
- package/dist/cli.js.map +1 -1
- package/dist/compare-5O6UTWPJ.js +405 -0
- package/dist/compare-5O6UTWPJ.js.map +1 -0
- package/dist/compare-profiles-7ZSNIW7B.js +218 -0
- package/dist/compare-profiles-7ZSNIW7B.js.map +1 -0
- package/dist/completion-I5U5VVAX.js +82 -0
- package/dist/completion-I5U5VVAX.js.map +1 -0
- package/dist/connection-GNTZDHXF.js +133 -0
- package/dist/connection-GNTZDHXF.js.map +1 -0
- package/dist/cost-estimate-TJDDH6TO.js +328 -0
- package/dist/cost-estimate-TJDDH6TO.js.map +1 -0
- package/dist/data-compare-UK2UXAS3.js +134 -0
- package/dist/data-compare-UK2UXAS3.js.map +1 -0
- package/dist/data-fit-Q45ENBRL.js +125 -0
- package/dist/data-fit-Q45ENBRL.js.map +1 -0
- package/dist/deploy-status-UUHKVDTI.js +58 -0
- package/dist/deploy-status-UUHKVDTI.js.map +1 -0
- package/dist/design-PO6UPBL7.js +138 -0
- package/dist/design-PO6UPBL7.js.map +1 -0
- package/dist/diagnose-6IFMELFR.js +145 -0
- package/dist/diagnose-6IFMELFR.js.map +1 -0
- package/dist/discover-A7OSZAHK.js +78 -0
- package/dist/discover-A7OSZAHK.js.map +1 -0
- package/dist/docs-CVRKGUSW.js +177 -0
- package/dist/docs-CVRKGUSW.js.map +1 -0
- package/dist/drift-XDA3BDYN.js +226 -0
- package/dist/drift-XDA3BDYN.js.map +1 -0
- package/dist/drift-gate-V7QSIOGZ.js +94 -0
- package/dist/drift-gate-V7QSIOGZ.js.map +1 -0
- package/dist/error-lookup-7ZWCZJ44.js +56 -0
- package/dist/error-lookup-7ZWCZJ44.js.map +1 -0
- package/dist/errorReporting-AQXKKGZH.js +109 -0
- package/dist/errorReporting-AQXKKGZH.js.map +1 -0
- package/dist/exec-PKBHLI7T.js +121 -0
- package/dist/exec-PKBHLI7T.js.map +1 -0
- package/dist/explain-LWKJOTL7.js +192 -0
- package/dist/explain-LWKJOTL7.js.map +1 -0
- package/dist/explorer-QOVM6VBD.js +61 -0
- package/dist/explorer-QOVM6VBD.js.map +1 -0
- package/dist/export-IYYBZ5HE.js +42 -0
- package/dist/export-IYYBZ5HE.js.map +1 -0
- package/dist/extract-VMMVRQVT.js +102 -0
- package/dist/extract-VMMVRQVT.js.map +1 -0
- package/dist/features-LE6BDZ2S.js +59 -0
- package/dist/features-LE6BDZ2S.js.map +1 -0
- package/dist/feedback-M7DM2EQC.js +161 -0
- package/dist/feedback-M7DM2EQC.js.map +1 -0
- package/dist/find-EME2JG2I.js +176 -0
- package/dist/find-EME2JG2I.js.map +1 -0
- package/dist/format-TRLWLMGS.js +141 -0
- package/dist/format-TRLWLMGS.js.map +1 -0
- package/dist/generate-6NAZGZDV.js +152 -0
- package/dist/generate-6NAZGZDV.js.map +1 -0
- package/dist/graph-QNQDAUO7.js +161 -0
- package/dist/graph-QNQDAUO7.js.map +1 -0
- package/dist/history-RONA7ZTI.js +199 -0
- package/dist/history-RONA7ZTI.js.map +1 -0
- package/dist/hosts-YBXY2ZG5.js +49 -0
- package/dist/hosts-YBXY2ZG5.js.map +1 -0
- package/dist/impact-T2JSANHS.js +59 -0
- package/dist/impact-T2JSANHS.js.map +1 -0
- package/dist/import-AELYLY6A.js +32 -0
- package/dist/import-AELYLY6A.js.map +1 -0
- package/dist/import-script-2OF5BI6A.js +83 -0
- package/dist/import-script-2OF5BI6A.js.map +1 -0
- package/dist/index.cjs +71 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +95 -31
- package/dist/index.js.map +1 -1
- package/dist/init-SWRRJMGI.js +57 -0
- package/dist/init-SWRRJMGI.js.map +1 -0
- package/dist/install-hooks-6SIAGTAF.js +109 -0
- package/dist/install-hooks-6SIAGTAF.js.map +1 -0
- package/dist/license-OAF22PLZ.js +46 -0
- package/dist/license-OAF22PLZ.js.map +1 -0
- package/dist/lineage-EW66XJ6O.js +552 -0
- package/dist/lineage-EW66XJ6O.js.map +1 -0
- package/dist/lint-FQ2OTYTQ.js +143 -0
- package/dist/lint-FQ2OTYTQ.js.map +1 -0
- package/dist/mcp-SARDMCDV.js +344 -0
- package/dist/mcp-SARDMCDV.js.map +1 -0
- package/dist/migrate-from-dbt-JVTXPWKQ.js +156 -0
- package/dist/migrate-from-dbt-JVTXPWKQ.js.map +1 -0
- package/dist/migrate-platform-NTRTOGNR.js +91 -0
- package/dist/migrate-platform-NTRTOGNR.js.map +1 -0
- package/dist/optimize-CJYWMAWA.js +105 -0
- package/dist/optimize-CJYWMAWA.js.map +1 -0
- package/dist/perf-LL2CPCJF.js +205 -0
- package/dist/perf-LL2CPCJF.js.map +1 -0
- package/dist/pii-FBDRDQ2E.js +136 -0
- package/dist/pii-FBDRDQ2E.js.map +1 -0
- package/dist/pilot-CCQERKPH.js +29 -0
- package/dist/pilot-CCQERKPH.js.map +1 -0
- package/dist/pr-comment-S5FF4QRX.js +79 -0
- package/dist/pr-comment-S5FF4QRX.js.map +1 -0
- package/dist/preview-5U4YVCRM.js +47 -0
- package/dist/preview-5U4YVCRM.js.map +1 -0
- package/dist/profile-7VC57KD2.js +101 -0
- package/dist/profile-7VC57KD2.js.map +1 -0
- package/dist/promote-AASEFTIA.js +408 -0
- package/dist/promote-AASEFTIA.js.map +1 -0
- package/dist/publish-UMVIWH6H.js +721 -0
- package/dist/publish-UMVIWH6H.js.map +1 -0
- package/dist/purge-QMXZKCMD.js +57 -0
- package/dist/purge-QMXZKCMD.js.map +1 -0
- package/dist/query-log-6OM4GI7W.js +112 -0
- package/dist/query-log-6OM4GI7W.js.map +1 -0
- package/dist/refactor-LTZQLJ35.js +5799 -0
- package/dist/refactor-LTZQLJ35.js.map +1 -0
- package/dist/refresh-4TY2AGOU.js +38 -0
- package/dist/refresh-4TY2AGOU.js.map +1 -0
- package/dist/replay-OOC25FZN.js +117 -0
- package/dist/replay-OOC25FZN.js.map +1 -0
- package/dist/revert-ODMUVJW6.js +110 -0
- package/dist/revert-ODMUVJW6.js.map +1 -0
- package/dist/review-XXPWOBFP.js +158 -0
- package/dist/review-XXPWOBFP.js.map +1 -0
- package/dist/rollback-suggest-6G2HEKFR.js +79 -0
- package/dist/rollback-suggest-6G2HEKFR.js.map +1 -0
- package/dist/safer-alternative-QFVNLG3L.js +89 -0
- package/dist/safer-alternative-QFVNLG3L.js.map +1 -0
- package/dist/safety-7QWRSUEZ.js +168 -0
- package/dist/safety-7QWRSUEZ.js.map +1 -0
- package/dist/savings-RHIXP6IT.js +95 -0
- package/dist/savings-RHIXP6IT.js.map +1 -0
- package/dist/scan-secrets-5YCQ4UCU.js +54 -0
- package/dist/scan-secrets-5YCQ4UCU.js.map +1 -0
- package/dist/schema-CIZXCQD2.js +429 -0
- package/dist/schema-CIZXCQD2.js.map +1 -0
- package/dist/script-K7CIN2P6.js +153 -0
- package/dist/script-K7CIN2P6.js.map +1 -0
- package/dist/search-BUZ5NXZZ.js +151 -0
- package/dist/search-BUZ5NXZZ.js.map +1 -0
- package/dist/seed-76QAK276.js +96 -0
- package/dist/seed-76QAK276.js.map +1 -0
- package/dist/sketch-PTLKDIK3.js +88 -0
- package/dist/sketch-PTLKDIK3.js.map +1 -0
- package/dist/snapshot-XLPR2OZ5.js +177 -0
- package/dist/snapshot-XLPR2OZ5.js.map +1 -0
- package/dist/snippets-EK4DK5CN.js +74 -0
- package/dist/snippets-EK4DK5CN.js.map +1 -0
- package/dist/standards-7T2UY6DD.js +241 -0
- package/dist/standards-7T2UY6DD.js.map +1 -0
- package/dist/suggest-VGRYSAR6.js +39 -0
- package/dist/suggest-VGRYSAR6.js.map +1 -0
- package/dist/suggest-constraints-MY5WKUHA.js +160 -0
- package/dist/suggest-constraints-MY5WKUHA.js.map +1 -0
- package/dist/suite-TRNGZWQM.js +88 -0
- package/dist/suite-TRNGZWQM.js.map +1 -0
- package/dist/telemetry-3U2QLA2S.js +75 -0
- package/dist/telemetry-3U2QLA2S.js.map +1 -0
- package/dist/template-ZERIXVXF.js +403 -0
- package/dist/template-ZERIXVXF.js.map +1 -0
- package/dist/test-5M2ED3WT.js +169 -0
- package/dist/test-5M2ED3WT.js.map +1 -0
- package/dist/trial-U732FONV.js +31 -0
- package/dist/trial-U732FONV.js.map +1 -0
- package/dist/validate-T6D2WCOK.js +106 -0
- package/dist/validate-T6D2WCOK.js.map +1 -0
- package/dist/verify-KXVASEEG.js +76 -0
- package/dist/verify-KXVASEEG.js.map +1 -0
- package/dist/watch-I6K4BNMA.js +80 -0
- package/dist/watch-I6K4BNMA.js.map +1 -0
- package/dist/xcompare-TPFLQO6W.js +87 -0
- package/dist/xcompare-TPFLQO6W.js.map +1 -0
- package/package.json +2 -2
- package/dist/cli.cjs +0 -19040
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.d.cts +0 -1
- package/dist/cli.d.ts +0 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attachExplainFlag,
|
|
3
|
+
runExplain
|
|
4
|
+
} from "./chunk-ZWY4ZRHL.js";
|
|
5
|
+
import "./chunk-VM2H4LAO.js";
|
|
6
|
+
import "./chunk-DGUM43GV.js";
|
|
7
|
+
|
|
8
|
+
// src/commands/cost-estimate.ts
|
|
9
|
+
import { promises as fs } from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { Command } from "commander";
|
|
12
|
+
function costEstimateCommand() {
|
|
13
|
+
const cmd = new Command("cost-estimate");
|
|
14
|
+
cmd.description("Heuristic Snowflake-credit estimate for a generated migration script.").requiredOption(
|
|
15
|
+
"--script <path>",
|
|
16
|
+
"Path to a generated migration script (.sql) from `sdt publish --out`."
|
|
17
|
+
).option(
|
|
18
|
+
"--warehouse-size <size>",
|
|
19
|
+
"Target warehouse size for the cost ladder: XS | S | M | L | XL | 2XL | 3XL | 4XL.",
|
|
20
|
+
"S"
|
|
21
|
+
).option("--format <fmt>", "table | json | markdown. Default table.", "table").option("-o, --out <path>", "Write output to file. Defaults to stdout.").option(
|
|
22
|
+
"--calibrate-from <path>",
|
|
23
|
+
"AI Phase 6 calibration: a JSON file of QueryHistoryEntry[] from prior deploys. Each historic statement is classified against the same cost-classes; classes with \u22653 samples adopt empirical min/max duration in place of the heuristic range."
|
|
24
|
+
).action(
|
|
25
|
+
async (opts) => {
|
|
26
|
+
const sql = await fs.readFile(path.resolve(String(opts.script)), "utf8");
|
|
27
|
+
const wh = String(opts.warehouseSize ?? "S").toUpperCase();
|
|
28
|
+
const wantedSizes = [
|
|
29
|
+
"XS",
|
|
30
|
+
"S",
|
|
31
|
+
"M",
|
|
32
|
+
"L",
|
|
33
|
+
"XL",
|
|
34
|
+
"2XL",
|
|
35
|
+
"3XL",
|
|
36
|
+
"4XL"
|
|
37
|
+
];
|
|
38
|
+
if (!wantedSizes.includes(wh)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unknown --warehouse-size: ${wh}. Use one of ${wantedSizes.join(" | ")}.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
let calibration;
|
|
44
|
+
if (opts.calibrateFrom) {
|
|
45
|
+
const histRaw = await fs.readFile(path.resolve(String(opts.calibrateFrom)), "utf8");
|
|
46
|
+
const parsed = JSON.parse(histRaw);
|
|
47
|
+
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
48
|
+
calibration = buildCalibration(entries);
|
|
49
|
+
}
|
|
50
|
+
const report = estimateCost(sql, wh, calibration);
|
|
51
|
+
const fmt = String(opts.format ?? "table").toLowerCase();
|
|
52
|
+
let payload;
|
|
53
|
+
if (fmt === "json") {
|
|
54
|
+
payload = JSON.stringify(report, null, 2);
|
|
55
|
+
} else if (fmt === "markdown") {
|
|
56
|
+
payload = renderMarkdown(report);
|
|
57
|
+
} else {
|
|
58
|
+
payload = renderTable(report);
|
|
59
|
+
}
|
|
60
|
+
if (opts.out) {
|
|
61
|
+
const p = path.resolve(String(opts.out));
|
|
62
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
63
|
+
await fs.writeFile(p, payload + (payload.endsWith("\n") ? "" : "\n"), "utf8");
|
|
64
|
+
console.error(`Wrote ${p}.`);
|
|
65
|
+
} else {
|
|
66
|
+
process.stdout.write(payload + (payload.endsWith("\n") ? "" : "\n"));
|
|
67
|
+
}
|
|
68
|
+
await runExplain(
|
|
69
|
+
{
|
|
70
|
+
feature: "cost-estimate.explain",
|
|
71
|
+
systemPrompt: "You are a Snowflake cost engineer. Walk the team through this cost estimate: what is the dominant cost driver, what does the range mean in practice, and what knobs (warehouse size, sequencing, off-peak timing) would meaningfully reduce it."
|
|
72
|
+
},
|
|
73
|
+
opts,
|
|
74
|
+
() => `Cost estimate (JSON) follows:
|
|
75
|
+
|
|
76
|
+
${JSON.stringify(report, null, 2)}
|
|
77
|
+
|
|
78
|
+
Narrate this for a teammate.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
attachExplainFlag(cmd);
|
|
83
|
+
return cmd;
|
|
84
|
+
}
|
|
85
|
+
var CREDITS_PER_HOUR = {
|
|
86
|
+
XS: 1,
|
|
87
|
+
S: 2,
|
|
88
|
+
M: 4,
|
|
89
|
+
L: 8,
|
|
90
|
+
XL: 16,
|
|
91
|
+
"2XL": 32,
|
|
92
|
+
"3XL": 64,
|
|
93
|
+
"4XL": 128
|
|
94
|
+
};
|
|
95
|
+
var COST_CLASSES = [
|
|
96
|
+
{
|
|
97
|
+
id: "table-rebuild",
|
|
98
|
+
description: "CREATE OR REPLACE TABLE or CTAS \u2014 rewrites the entire table",
|
|
99
|
+
minSeconds: 30,
|
|
100
|
+
maxSeconds: 1800,
|
|
101
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?TABLE\s+.+\s+AS\s+SELECT/i.test(s) || /CREATE\s+OR\s+REPLACE\s+TABLE\b/i.test(s)
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "add-column",
|
|
105
|
+
description: "ALTER TABLE ADD COLUMN \u2014 metadata-only, no scan",
|
|
106
|
+
minSeconds: 1,
|
|
107
|
+
maxSeconds: 5,
|
|
108
|
+
test: (s) => /ALTER\s+TABLE\b.*\bADD\s+COLUMN\b/i.test(s)
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "drop-column",
|
|
112
|
+
description: "ALTER TABLE DROP COLUMN \u2014 metadata-only",
|
|
113
|
+
minSeconds: 1,
|
|
114
|
+
maxSeconds: 5,
|
|
115
|
+
test: (s) => /ALTER\s+TABLE\b.*\bDROP\s+COLUMN\b/i.test(s)
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "alter-type-narrowing",
|
|
119
|
+
description: "ALTER COLUMN TYPE (narrowing) \u2014 full data scan + rewrite",
|
|
120
|
+
minSeconds: 30,
|
|
121
|
+
maxSeconds: 600,
|
|
122
|
+
test: (s) => /ALTER\s+TABLE\b.*\bALTER\s+COLUMN\b.*\bSET\s+DATA\s+TYPE\b/i.test(s)
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "create-view",
|
|
126
|
+
description: "CREATE OR REPLACE VIEW \u2014 metadata-only",
|
|
127
|
+
minSeconds: 1,
|
|
128
|
+
maxSeconds: 3,
|
|
129
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\b/i.test(s)
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "create-mv",
|
|
133
|
+
description: "CREATE MATERIALIZED VIEW \u2014 initial population can be costly",
|
|
134
|
+
minSeconds: 60,
|
|
135
|
+
maxSeconds: 3600,
|
|
136
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?MATERIALIZED\s+VIEW\b/i.test(s)
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "create-dynamic-table",
|
|
140
|
+
description: "CREATE DYNAMIC TABLE \u2014 initial materialisation cost",
|
|
141
|
+
minSeconds: 60,
|
|
142
|
+
maxSeconds: 1800,
|
|
143
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?DYNAMIC\s+TABLE\b/i.test(s)
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "create-stream",
|
|
147
|
+
description: "CREATE STREAM \u2014 metadata-only",
|
|
148
|
+
minSeconds: 1,
|
|
149
|
+
maxSeconds: 3,
|
|
150
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?STREAM\b/i.test(s)
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: "create-task",
|
|
154
|
+
description: "CREATE TASK \u2014 metadata-only",
|
|
155
|
+
minSeconds: 1,
|
|
156
|
+
maxSeconds: 3,
|
|
157
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?TASK\b/i.test(s)
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "create-warehouse",
|
|
161
|
+
description: "CREATE WAREHOUSE \u2014 metadata; cost depends on usage",
|
|
162
|
+
minSeconds: 1,
|
|
163
|
+
maxSeconds: 5,
|
|
164
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?WAREHOUSE\b/i.test(s)
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "pre-deploy-clone",
|
|
168
|
+
description: "CREATE DATABASE ... CLONE \u2014 Snowflake zero-copy clone (~instant)",
|
|
169
|
+
minSeconds: 1,
|
|
170
|
+
maxSeconds: 10,
|
|
171
|
+
test: (s) => /CREATE\s+(?:OR\s+REPLACE\s+)?DATABASE\s+\S+\s+CLONE\b/i.test(s)
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "drop",
|
|
175
|
+
description: "DROP statement \u2014 metadata-only",
|
|
176
|
+
minSeconds: 1,
|
|
177
|
+
maxSeconds: 3,
|
|
178
|
+
test: (s) => /^\s*DROP\s+/i.test(s)
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "grant",
|
|
182
|
+
description: "GRANT / REVOKE \u2014 metadata-only",
|
|
183
|
+
minSeconds: 1,
|
|
184
|
+
maxSeconds: 2,
|
|
185
|
+
test: (s) => /^\s*(?:GRANT|REVOKE)\s+/i.test(s)
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "comment",
|
|
189
|
+
description: "COMMENT-only statement (-- \u2026 no DDL) \u2014 free",
|
|
190
|
+
minSeconds: 0,
|
|
191
|
+
maxSeconds: 0,
|
|
192
|
+
test: (s) => s.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").trim().length === 0
|
|
193
|
+
}
|
|
194
|
+
];
|
|
195
|
+
function buildCalibration(entries) {
|
|
196
|
+
const samples = /* @__PURE__ */ new Map();
|
|
197
|
+
for (const e of entries) {
|
|
198
|
+
const sql = typeof e.sqlText === "string" ? e.sqlText : "";
|
|
199
|
+
const dur = typeof e.durationMs === "number" ? e.durationMs : NaN;
|
|
200
|
+
if (!sql || !isFinite(dur) || dur <= 0) continue;
|
|
201
|
+
const matched = COST_CLASSES.find((c) => c.test(sql));
|
|
202
|
+
if (!matched) continue;
|
|
203
|
+
const arr = samples.get(matched.id) ?? [];
|
|
204
|
+
arr.push(dur / 1e3);
|
|
205
|
+
samples.set(matched.id, arr);
|
|
206
|
+
}
|
|
207
|
+
const perClass = {};
|
|
208
|
+
for (const [id, durs] of samples) {
|
|
209
|
+
if (durs.length < 3) continue;
|
|
210
|
+
durs.sort((a, b) => a - b);
|
|
211
|
+
perClass[id] = {
|
|
212
|
+
sampleSize: durs.length,
|
|
213
|
+
minSeconds: Math.max(1, Math.round(durs[0])),
|
|
214
|
+
maxSeconds: Math.max(1, Math.round(durs[durs.length - 1]))
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { perClass };
|
|
218
|
+
}
|
|
219
|
+
function estimateCost(sql, warehouseSize, calibration) {
|
|
220
|
+
const statements = splitStatements(sql);
|
|
221
|
+
const classCounts = /* @__PURE__ */ new Map();
|
|
222
|
+
let classifiedStatements = 0;
|
|
223
|
+
let unknownStatements = 0;
|
|
224
|
+
for (const stmt of statements) {
|
|
225
|
+
const matched = COST_CLASSES.find((c) => c.test(stmt));
|
|
226
|
+
if (matched) {
|
|
227
|
+
classCounts.set(matched.id, (classCounts.get(matched.id) ?? 0) + 1);
|
|
228
|
+
classifiedStatements++;
|
|
229
|
+
} else if (stmt.trim().length > 0) {
|
|
230
|
+
unknownStatements++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const creditsPerHour = CREDITS_PER_HOUR[warehouseSize];
|
|
234
|
+
const perClass = COST_CLASSES.filter((c) => (classCounts.get(c.id) ?? 0) > 0).map((c) => {
|
|
235
|
+
const count = classCounts.get(c.id) ?? 0;
|
|
236
|
+
const cal = calibration?.perClass[c.id];
|
|
237
|
+
const minSecondsPerStmt = cal ? cal.minSeconds : c.minSeconds;
|
|
238
|
+
const maxSecondsPerStmt = cal ? cal.maxSeconds : c.maxSeconds;
|
|
239
|
+
const minSeconds = minSecondsPerStmt * count;
|
|
240
|
+
const maxSeconds = maxSecondsPerStmt * count;
|
|
241
|
+
return {
|
|
242
|
+
id: c.id,
|
|
243
|
+
description: c.description,
|
|
244
|
+
count,
|
|
245
|
+
minSeconds,
|
|
246
|
+
maxSeconds,
|
|
247
|
+
minCredits: minSeconds / 3600 * creditsPerHour,
|
|
248
|
+
maxCredits: maxSeconds / 3600 * creditsPerHour,
|
|
249
|
+
...cal ? { calibratedFromSamples: cal.sampleSize } : {}
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
const totals = perClass.reduce(
|
|
253
|
+
(acc, c) => ({
|
|
254
|
+
minSeconds: acc.minSeconds + c.minSeconds,
|
|
255
|
+
maxSeconds: acc.maxSeconds + c.maxSeconds,
|
|
256
|
+
minCredits: acc.minCredits + c.minCredits,
|
|
257
|
+
maxCredits: acc.maxCredits + c.maxCredits
|
|
258
|
+
}),
|
|
259
|
+
{ minSeconds: 0, maxSeconds: 0, minCredits: 0, maxCredits: 0 }
|
|
260
|
+
);
|
|
261
|
+
return {
|
|
262
|
+
warehouseSize,
|
|
263
|
+
creditsPerHour,
|
|
264
|
+
totalStatements: statements.length,
|
|
265
|
+
classifiedStatements,
|
|
266
|
+
unknownStatements,
|
|
267
|
+
perClass,
|
|
268
|
+
totals
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function splitStatements(sql) {
|
|
272
|
+
return sql.replace(/\/\*[\s\S]*?\*\//g, "").split(/;\s*$/m).map((s) => s.trim()).filter(Boolean);
|
|
273
|
+
}
|
|
274
|
+
function renderTable(r) {
|
|
275
|
+
const lines = [];
|
|
276
|
+
lines.push(`Cost estimate \u2014 warehouse=${r.warehouseSize} (${r.creditsPerHour} credit/hour)`);
|
|
277
|
+
lines.push("");
|
|
278
|
+
lines.push(
|
|
279
|
+
` Statements: ${r.totalStatements} (${r.classifiedStatements} classified, ${r.unknownStatements} unknown).`
|
|
280
|
+
);
|
|
281
|
+
lines.push("");
|
|
282
|
+
const idW = Math.max(20, ...r.perClass.map((c) => c.id.length));
|
|
283
|
+
for (const c of r.perClass) {
|
|
284
|
+
lines.push(
|
|
285
|
+
` ${c.id.padEnd(idW)} \xD7${String(c.count).padStart(4)} \u2248 ${c.minCredits.toFixed(3)} \u2013 ${c.maxCredits.toFixed(3)} credit`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
lines.push("");
|
|
289
|
+
lines.push(
|
|
290
|
+
` TOTAL \u2248 ${r.totals.minCredits.toFixed(3)} \u2013 ${r.totals.maxCredits.toFixed(3)} credit`
|
|
291
|
+
);
|
|
292
|
+
lines.push(` TOTAL (duration estimate) \u2248 ${r.totals.minSeconds}s \u2013 ${r.totals.maxSeconds}s`);
|
|
293
|
+
lines.push("");
|
|
294
|
+
lines.push(" Note: ranges are heuristic. Actual cost depends on row counts, data layout,");
|
|
295
|
+
lines.push(" and what other workloads share the warehouse. Treat the upper bound as a");
|
|
296
|
+
lines.push(" pessimistic ceiling, not a likely outcome.");
|
|
297
|
+
return lines.join("\n");
|
|
298
|
+
}
|
|
299
|
+
function renderMarkdown(r) {
|
|
300
|
+
const lines = [];
|
|
301
|
+
lines.push(`# Cost estimate`);
|
|
302
|
+
lines.push("");
|
|
303
|
+
lines.push(`**Warehouse:** ${r.warehouseSize} (${r.creditsPerHour} credit/hour)`);
|
|
304
|
+
lines.push(
|
|
305
|
+
`**Statements:** ${r.totalStatements} total, ${r.classifiedStatements} classified, ${r.unknownStatements} unknown`
|
|
306
|
+
);
|
|
307
|
+
lines.push("");
|
|
308
|
+
lines.push(`## Estimate by statement class`);
|
|
309
|
+
lines.push("");
|
|
310
|
+
lines.push("| Class | Count | Credit range |");
|
|
311
|
+
lines.push("|---|---|---|");
|
|
312
|
+
for (const c of r.perClass) {
|
|
313
|
+
lines.push(
|
|
314
|
+
`| \`${c.id}\` (${c.description}) | ${c.count} | ${c.minCredits.toFixed(3)} \u2013 ${c.maxCredits.toFixed(3)} |`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push(
|
|
319
|
+
`**Total:** ${r.totals.minCredits.toFixed(3)} \u2013 ${r.totals.maxCredits.toFixed(3)} credit (${r.totals.minSeconds}\u2013${r.totals.maxSeconds}s)`
|
|
320
|
+
);
|
|
321
|
+
lines.push("");
|
|
322
|
+
lines.push("> Ranges are heuristic. Actual cost depends on row counts and warehouse contention.");
|
|
323
|
+
return lines.join("\n");
|
|
324
|
+
}
|
|
325
|
+
export {
|
|
326
|
+
costEstimateCommand
|
|
327
|
+
};
|
|
328
|
+
//# sourceMappingURL=cost-estimate-TJDDH6TO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/commands/cost-estimate.ts"],"sourcesContent":["import { promises as fs } from 'node:fs';\nimport path from 'node:path';\nimport { Command } from 'commander';\nimport { attachExplainFlag, runExplain } from '../util/ai-explain.js';\n\ninterface HistoryEntry {\n sqlText?: string;\n durationMs?: number;\n}\n\n/**\n * `sdt cost-estimate` — static heuristic estimator for the Snowflake\n * credits a migration script will consume when applied.\n *\n * Approach: classify each statement by its observable cost-class\n * (TABLE rebuild, CREATE OR REPLACE VIEW, ADD COLUMN, ALTER TYPE,\n * dynamic-table refresh, etc.) and assign a credit-band estimate per\n * class. Estimates are intentionally **ranges** rather than point\n * values because actual cost depends on row count, warehouse size,\n * and data layout — we don't pretend otherwise.\n *\n * The framing alone is uniquely valuable: no other Snowflake schema\n * tool surfaces \"this migration will roughly cost X credits at S\n * warehouse\" before you apply it.\n *\n * The classifier is regex-based and conservative — when a statement\n * doesn't match a known cost-class it's reported as\n * \"unknown / negligible\" so the user knows the estimate didn't see it.\n */\nexport function costEstimateCommand(): Command {\n const cmd = new Command('cost-estimate');\n cmd\n .description('Heuristic Snowflake-credit estimate for a generated migration script.')\n .requiredOption(\n '--script <path>',\n 'Path to a generated migration script (.sql) from `sdt publish --out`.',\n )\n .option(\n '--warehouse-size <size>',\n 'Target warehouse size for the cost ladder: XS | S | M | L | XL | 2XL | 3XL | 4XL.',\n 'S',\n )\n .option('--format <fmt>', 'table | json | markdown. Default table.', 'table')\n .option('-o, --out <path>', 'Write output to file. Defaults to stdout.')\n .option(\n '--calibrate-from <path>',\n 'AI Phase 6 calibration: a JSON file of QueryHistoryEntry[] from prior deploys. Each historic statement is classified against the same cost-classes; classes with ≥3 samples adopt empirical min/max duration in place of the heuristic range.',\n )\n .action(\n async (opts: {\n script: string;\n warehouseSize?: string;\n format?: string;\n out?: string;\n calibrateFrom?: string;\n explain?: boolean;\n }) => {\n const sql = await fs.readFile(path.resolve(String(opts.script)), 'utf8');\n const wh = String(opts.warehouseSize ?? 'S').toUpperCase() as WarehouseSize;\n const wantedSizes: readonly WarehouseSize[] = [\n 'XS',\n 'S',\n 'M',\n 'L',\n 'XL',\n '2XL',\n '3XL',\n '4XL',\n ];\n if (!wantedSizes.includes(wh)) {\n throw new Error(\n `Unknown --warehouse-size: ${wh}. Use one of ${wantedSizes.join(' | ')}.`,\n );\n }\n let calibration: ClassCalibration | undefined;\n if (opts.calibrateFrom) {\n const histRaw = await fs.readFile(path.resolve(String(opts.calibrateFrom)), 'utf8');\n const parsed = JSON.parse(histRaw);\n const entries: HistoryEntry[] = Array.isArray(parsed)\n ? parsed\n : Array.isArray(parsed.entries)\n ? parsed.entries\n : [];\n calibration = buildCalibration(entries);\n }\n const report = estimateCost(sql, wh, calibration);\n\n const fmt = String(opts.format ?? 'table').toLowerCase();\n let payload: string;\n if (fmt === 'json') {\n payload = JSON.stringify(report, null, 2);\n } else if (fmt === 'markdown') {\n payload = renderMarkdown(report);\n } else {\n payload = renderTable(report);\n }\n if (opts.out) {\n const p = path.resolve(String(opts.out));\n await fs.mkdir(path.dirname(p), { recursive: true });\n await fs.writeFile(p, payload + (payload.endsWith('\\n') ? '' : '\\n'), 'utf8');\n console.error(`Wrote ${p}.`);\n } else {\n process.stdout.write(payload + (payload.endsWith('\\n') ? '' : '\\n'));\n }\n await runExplain(\n {\n feature: 'cost-estimate.explain',\n systemPrompt:\n 'You are a Snowflake cost engineer. Walk the team through this cost estimate: what is the dominant cost driver, what does the range mean in practice, and what knobs (warehouse size, sequencing, off-peak timing) would meaningfully reduce it.',\n },\n opts,\n () =>\n `Cost estimate (JSON) follows:\\n\\n${JSON.stringify(report, null, 2)}\\n\\nNarrate this for a teammate.`,\n );\n },\n );\n attachExplainFlag(cmd);\n return cmd;\n}\n\ntype WarehouseSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | '2XL' | '3XL' | '4XL';\n\n/**\n * Snowflake's published credit-per-hour ladder, as of 2026-Q1.\n * XS = 1, S = 2, M = 4, L = 8, XL = 16, 2XL = 32, 3XL = 64, 4XL = 128.\n */\nconst CREDITS_PER_HOUR: Record<WarehouseSize, number> = {\n XS: 1,\n S: 2,\n M: 4,\n L: 8,\n XL: 16,\n '2XL': 32,\n '3XL': 64,\n '4XL': 128,\n};\n\ninterface CostClass {\n id: string;\n description: string;\n /** Conservative range estimate in **seconds of warehouse time**. */\n minSeconds: number;\n maxSeconds: number;\n /** Regex that matches the statement category. */\n test: (sql: string) => boolean;\n}\n\nconst COST_CLASSES: CostClass[] = [\n {\n id: 'table-rebuild',\n description: 'CREATE OR REPLACE TABLE or CTAS — rewrites the entire table',\n minSeconds: 30,\n maxSeconds: 1800,\n test: (s) =>\n /CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+.+\\s+AS\\s+SELECT/i.test(s) ||\n /CREATE\\s+OR\\s+REPLACE\\s+TABLE\\b/i.test(s),\n },\n {\n id: 'add-column',\n description: 'ALTER TABLE ADD COLUMN — metadata-only, no scan',\n minSeconds: 1,\n maxSeconds: 5,\n test: (s) => /ALTER\\s+TABLE\\b.*\\bADD\\s+COLUMN\\b/i.test(s),\n },\n {\n id: 'drop-column',\n description: 'ALTER TABLE DROP COLUMN — metadata-only',\n minSeconds: 1,\n maxSeconds: 5,\n test: (s) => /ALTER\\s+TABLE\\b.*\\bDROP\\s+COLUMN\\b/i.test(s),\n },\n {\n id: 'alter-type-narrowing',\n description: 'ALTER COLUMN TYPE (narrowing) — full data scan + rewrite',\n minSeconds: 30,\n maxSeconds: 600,\n test: (s) => /ALTER\\s+TABLE\\b.*\\bALTER\\s+COLUMN\\b.*\\bSET\\s+DATA\\s+TYPE\\b/i.test(s),\n },\n {\n id: 'create-view',\n description: 'CREATE OR REPLACE VIEW — metadata-only',\n minSeconds: 1,\n maxSeconds: 3,\n test: (s) => /CREATE\\s+(?:OR\\s+REPLACE\\s+)?VIEW\\b/i.test(s),\n },\n {\n id: 'create-mv',\n description: 'CREATE MATERIALIZED VIEW — initial population can be costly',\n minSeconds: 60,\n maxSeconds: 3600,\n test: (s) => /CREATE\\s+(?:OR\\s+REPLACE\\s+)?MATERIALIZED\\s+VIEW\\b/i.test(s),\n },\n {\n id: 'create-dynamic-table',\n description: 'CREATE DYNAMIC TABLE — initial materialisation cost',\n minSeconds: 60,\n maxSeconds: 1800,\n test: (s) => /CREATE\\s+(?:OR\\s+REPLACE\\s+)?DYNAMIC\\s+TABLE\\b/i.test(s),\n },\n {\n id: 'create-stream',\n description: 'CREATE STREAM — metadata-only',\n minSeconds: 1,\n maxSeconds: 3,\n test: (s) => /CREATE\\s+(?:OR\\s+REPLACE\\s+)?STREAM\\b/i.test(s),\n },\n {\n id: 'create-task',\n description: 'CREATE TASK — metadata-only',\n minSeconds: 1,\n maxSeconds: 3,\n test: (s) => /CREATE\\s+(?:OR\\s+REPLACE\\s+)?TASK\\b/i.test(s),\n },\n {\n id: 'create-warehouse',\n description: 'CREATE WAREHOUSE — metadata; cost depends on usage',\n minSeconds: 1,\n maxSeconds: 5,\n test: (s) => /CREATE\\s+(?:OR\\s+REPLACE\\s+)?WAREHOUSE\\b/i.test(s),\n },\n {\n id: 'pre-deploy-clone',\n description: 'CREATE DATABASE ... CLONE — Snowflake zero-copy clone (~instant)',\n minSeconds: 1,\n maxSeconds: 10,\n test: (s) => /CREATE\\s+(?:OR\\s+REPLACE\\s+)?DATABASE\\s+\\S+\\s+CLONE\\b/i.test(s),\n },\n {\n id: 'drop',\n description: 'DROP statement — metadata-only',\n minSeconds: 1,\n maxSeconds: 3,\n test: (s) => /^\\s*DROP\\s+/i.test(s),\n },\n {\n id: 'grant',\n description: 'GRANT / REVOKE — metadata-only',\n minSeconds: 1,\n maxSeconds: 2,\n test: (s) => /^\\s*(?:GRANT|REVOKE)\\s+/i.test(s),\n },\n {\n id: 'comment',\n description: 'COMMENT-only statement (-- … no DDL) — free',\n minSeconds: 0,\n maxSeconds: 0,\n test: (s) =>\n s\n .replace(/--[^\\n]*/g, '')\n .replace(/\\/\\*[\\s\\S]*?\\*\\//g, '')\n .trim().length === 0,\n },\n];\n\ninterface CostReport {\n warehouseSize: WarehouseSize;\n creditsPerHour: number;\n totalStatements: number;\n classifiedStatements: number;\n unknownStatements: number;\n perClass: Array<{\n id: string;\n description: string;\n count: number;\n minSeconds: number;\n maxSeconds: number;\n minCredits: number;\n maxCredits: number;\n /** When set, the per-statement range came from this many empirical samples. */\n calibratedFromSamples?: number;\n }>;\n totals: {\n minSeconds: number;\n maxSeconds: number;\n minCredits: number;\n maxCredits: number;\n };\n}\n\n/**\n * AI Phase 6 calibration — per-class empirical duration ranges harvested\n * from prior `QueryHistoryEntry[]` (typed in `@<tool>/core/queryHistory`).\n * When a class has at least 3 historic samples, its heuristic min/max is\n * replaced with the observed min/max. Classes that fall back to the\n * heuristic are flagged in the report so the user can see what was\n * calibrated.\n */\ninterface ClassCalibration {\n /** Per-class empirical range; absent classes use the heuristic range. */\n perClass: Record<string, { sampleSize: number; minSeconds: number; maxSeconds: number }>;\n}\n\nfunction buildCalibration(entries: HistoryEntry[]): ClassCalibration {\n const samples = new Map<string, number[]>();\n for (const e of entries) {\n const sql = typeof e.sqlText === 'string' ? e.sqlText : '';\n const dur = typeof e.durationMs === 'number' ? e.durationMs : NaN;\n if (!sql || !isFinite(dur) || dur <= 0) continue;\n const matched = COST_CLASSES.find((c) => c.test(sql));\n if (!matched) continue;\n const arr = samples.get(matched.id) ?? [];\n arr.push(dur / 1000);\n samples.set(matched.id, arr);\n }\n const perClass: ClassCalibration['perClass'] = {};\n for (const [id, durs] of samples) {\n if (durs.length < 3) continue;\n durs.sort((a, b) => a - b);\n perClass[id] = {\n sampleSize: durs.length,\n minSeconds: Math.max(1, Math.round(durs[0]!)),\n maxSeconds: Math.max(1, Math.round(durs[durs.length - 1]!)),\n };\n }\n return { perClass };\n}\n\nfunction estimateCost(\n sql: string,\n warehouseSize: WarehouseSize,\n calibration?: ClassCalibration,\n): CostReport {\n const statements = splitStatements(sql);\n const classCounts = new Map<string, number>();\n let classifiedStatements = 0;\n let unknownStatements = 0;\n for (const stmt of statements) {\n const matched = COST_CLASSES.find((c) => c.test(stmt));\n if (matched) {\n classCounts.set(matched.id, (classCounts.get(matched.id) ?? 0) + 1);\n classifiedStatements++;\n } else if (stmt.trim().length > 0) {\n unknownStatements++;\n }\n }\n const creditsPerHour = CREDITS_PER_HOUR[warehouseSize];\n const perClass = COST_CLASSES.filter((c) => (classCounts.get(c.id) ?? 0) > 0).map((c) => {\n const count = classCounts.get(c.id) ?? 0;\n const cal = calibration?.perClass[c.id];\n const minSecondsPerStmt = cal ? cal.minSeconds : c.minSeconds;\n const maxSecondsPerStmt = cal ? cal.maxSeconds : c.maxSeconds;\n const minSeconds = minSecondsPerStmt * count;\n const maxSeconds = maxSecondsPerStmt * count;\n return {\n id: c.id,\n description: c.description,\n count,\n minSeconds,\n maxSeconds,\n minCredits: (minSeconds / 3600) * creditsPerHour,\n maxCredits: (maxSeconds / 3600) * creditsPerHour,\n ...(cal ? { calibratedFromSamples: cal.sampleSize } : {}),\n };\n });\n const totals = perClass.reduce(\n (acc, c) => ({\n minSeconds: acc.minSeconds + c.minSeconds,\n maxSeconds: acc.maxSeconds + c.maxSeconds,\n minCredits: acc.minCredits + c.minCredits,\n maxCredits: acc.maxCredits + c.maxCredits,\n }),\n { minSeconds: 0, maxSeconds: 0, minCredits: 0, maxCredits: 0 },\n );\n return {\n warehouseSize,\n creditsPerHour,\n totalStatements: statements.length,\n classifiedStatements,\n unknownStatements,\n perClass,\n totals,\n };\n}\n\nfunction splitStatements(sql: string): string[] {\n return sql\n .replace(/\\/\\*[\\s\\S]*?\\*\\//g, '')\n .split(/;\\s*$/m)\n .map((s) => s.trim())\n .filter(Boolean);\n}\n\nfunction renderTable(r: CostReport): string {\n const lines: string[] = [];\n lines.push(`Cost estimate — warehouse=${r.warehouseSize} (${r.creditsPerHour} credit/hour)`);\n lines.push('');\n lines.push(\n ` Statements: ${r.totalStatements} (${r.classifiedStatements} classified, ${r.unknownStatements} unknown).`,\n );\n lines.push('');\n const idW = Math.max(20, ...r.perClass.map((c) => c.id.length));\n for (const c of r.perClass) {\n lines.push(\n ` ${c.id.padEnd(idW)} ×${String(c.count).padStart(4)} ≈ ${c.minCredits.toFixed(3)} – ${c.maxCredits.toFixed(3)} credit`,\n );\n }\n lines.push('');\n lines.push(\n ` TOTAL ≈ ${r.totals.minCredits.toFixed(3)} – ${r.totals.maxCredits.toFixed(3)} credit`,\n );\n lines.push(` TOTAL (duration estimate) ≈ ${r.totals.minSeconds}s – ${r.totals.maxSeconds}s`);\n lines.push('');\n lines.push(' Note: ranges are heuristic. Actual cost depends on row counts, data layout,');\n lines.push(' and what other workloads share the warehouse. Treat the upper bound as a');\n lines.push(' pessimistic ceiling, not a likely outcome.');\n return lines.join('\\n');\n}\n\nfunction renderMarkdown(r: CostReport): string {\n const lines: string[] = [];\n lines.push(`# Cost estimate`);\n lines.push('');\n lines.push(`**Warehouse:** ${r.warehouseSize} (${r.creditsPerHour} credit/hour)`);\n lines.push(\n `**Statements:** ${r.totalStatements} total, ${r.classifiedStatements} classified, ${r.unknownStatements} unknown`,\n );\n lines.push('');\n lines.push(`## Estimate by statement class`);\n lines.push('');\n lines.push('| Class | Count | Credit range |');\n lines.push('|---|---|---|');\n for (const c of r.perClass) {\n lines.push(\n `| \\`${c.id}\\` (${c.description}) | ${c.count} | ${c.minCredits.toFixed(3)} – ${c.maxCredits.toFixed(3)} |`,\n );\n }\n lines.push('');\n lines.push(\n `**Total:** ${r.totals.minCredits.toFixed(3)} – ${r.totals.maxCredits.toFixed(3)} credit (${r.totals.minSeconds}–${r.totals.maxSeconds}s)`,\n );\n lines.push('');\n lines.push('> Ranges are heuristic. Actual cost depends on row counts and warehouse contention.');\n return lines.join('\\n');\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
logger
|
|
3
|
+
} from "./chunk-VM2H4LAO.js";
|
|
4
|
+
import "./chunk-DGUM43GV.js";
|
|
5
|
+
|
|
6
|
+
// src/commands/data-compare.ts
|
|
7
|
+
import { promises as fs } from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { dataCompare } from "@sdt-tools/core";
|
|
11
|
+
import { getProfile, SnowflakeConnection } from "@sdt-tools/core/connection";
|
|
12
|
+
function dataCompareCommand() {
|
|
13
|
+
const cmd = new Command("data-compare");
|
|
14
|
+
cmd.description(
|
|
15
|
+
"Row-level data compare: diff two tables by primary key and emit an INSERT/UPDATE/DELETE script that converts target into source. Source/target rows read from JSON files in v1."
|
|
16
|
+
).requiredOption(
|
|
17
|
+
"--table <fqn>",
|
|
18
|
+
"Fully-qualified table name to embed in the emitted script (DATABASE.SCHEMA.TABLE)."
|
|
19
|
+
).requiredOption("--key <cols>", "Comma-separated primary-key columns.").option(
|
|
20
|
+
"--source <path>",
|
|
21
|
+
"JSON file containing the source-side rows (TableRow[]). Mutually exclusive with --source-live."
|
|
22
|
+
).option(
|
|
23
|
+
"--target <path>",
|
|
24
|
+
"JSON file containing the target-side rows (TableRow[]). Mutually exclusive with --target-live."
|
|
25
|
+
).option(
|
|
26
|
+
"--source-live <profile>",
|
|
27
|
+
"Connection profile to read source rows from live via SELECT * FROM --table. Mutually exclusive with --source."
|
|
28
|
+
).option(
|
|
29
|
+
"--target-live <profile>",
|
|
30
|
+
"Connection profile to read target rows from live via SELECT * FROM --table. Mutually exclusive with --target."
|
|
31
|
+
).option(
|
|
32
|
+
"--row-limit <n>",
|
|
33
|
+
"Cap rows fetched per side in live mode. Default 100000. Use 0 for unbounded (DANGER on prod tables).",
|
|
34
|
+
"100000"
|
|
35
|
+
).option(
|
|
36
|
+
"--columns <cols>",
|
|
37
|
+
"Comma-separated column list to consider. Defaults to all keys seen in the rows."
|
|
38
|
+
).option(
|
|
39
|
+
"--max-changes <n>",
|
|
40
|
+
"Cap the changed-row count (added/removed are not capped). Default 10000.",
|
|
41
|
+
"10000"
|
|
42
|
+
).option(
|
|
43
|
+
"--case-sensitive",
|
|
44
|
+
"Compare column names case-sensitively (default: false \u2014 Snowflake idiom).",
|
|
45
|
+
false
|
|
46
|
+
).option("--no-transaction", "Omit BEGIN/COMMIT wrappers from the emitted script.").option("--no-header", "Omit the REVIEW header from the emitted script.").option("--format <fmt>", "Output format: sql | json. Default sql.", "sql").option("-o, --output <path>", "Write output to a file instead of stdout.").action(async (opts) => {
|
|
47
|
+
await runDataCompare(opts, "snowflake");
|
|
48
|
+
});
|
|
49
|
+
return cmd;
|
|
50
|
+
}
|
|
51
|
+
async function runDataCompare(opts, dialect) {
|
|
52
|
+
const fqn = String(opts.table);
|
|
53
|
+
const keyColumns = String(opts.key).split(",").map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
if (keyColumns.length === 0) {
|
|
55
|
+
throw new Error("--key must include at least one column.");
|
|
56
|
+
}
|
|
57
|
+
const columns = opts.columns ? String(opts.columns).split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
58
|
+
const sourceJson = opts.source;
|
|
59
|
+
const targetJson = opts.target;
|
|
60
|
+
const sourceLive = opts.sourceLive;
|
|
61
|
+
const targetLive = opts.targetLive;
|
|
62
|
+
if (Boolean(sourceJson) === Boolean(sourceLive)) {
|
|
63
|
+
throw new Error("Specify exactly one of --source or --source-live.");
|
|
64
|
+
}
|
|
65
|
+
if (Boolean(targetJson) === Boolean(targetLive)) {
|
|
66
|
+
throw new Error("Specify exactly one of --target or --target-live.");
|
|
67
|
+
}
|
|
68
|
+
const rowLimit = Number(opts.rowLimit ?? "100000");
|
|
69
|
+
const [sourceRows, targetRows] = await Promise.all([
|
|
70
|
+
sourceLive ? fetchLiveRows(sourceLive, fqn, columns, rowLimit, "source") : readRowsFile(String(sourceJson)),
|
|
71
|
+
targetLive ? fetchLiveRows(targetLive, fqn, columns, rowLimit, "target") : readRowsFile(String(targetJson))
|
|
72
|
+
]);
|
|
73
|
+
const result = dataCompare.diffRows(
|
|
74
|
+
{
|
|
75
|
+
fqn,
|
|
76
|
+
keyColumns,
|
|
77
|
+
...columns ? { columns } : {},
|
|
78
|
+
sourceRows,
|
|
79
|
+
targetRows
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
maxChanges: Number(opts.maxChanges ?? "10000") || 1e4,
|
|
83
|
+
caseSensitiveColumns: opts.caseSensitive === true
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
const format = String(opts.format).toLowerCase();
|
|
87
|
+
const out = format === "json" ? JSON.stringify(result, null, 2) : dataCompare.renderDataCompareScript(result, dialect, {
|
|
88
|
+
transactional: opts.transaction !== false,
|
|
89
|
+
includeHeader: opts.header !== false
|
|
90
|
+
});
|
|
91
|
+
if (opts.output) {
|
|
92
|
+
const outPath = path.resolve(String(opts.output));
|
|
93
|
+
await fs.writeFile(outPath, out, "utf8");
|
|
94
|
+
logger.info(
|
|
95
|
+
`data-compare: wrote ${outPath} (added=${result.diff.added.length} removed=${result.diff.removed.length} changed=${result.diff.changed.length} unchanged=${result.diff.unchanged}${result.truncated ? " truncated" : ""})`
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
process.stdout.write(out);
|
|
99
|
+
if (format !== "json") process.stdout.write("\n");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function readRowsFile(p) {
|
|
103
|
+
const raw = await fs.readFile(path.resolve(p), "utf8");
|
|
104
|
+
const parsed = JSON.parse(raw);
|
|
105
|
+
if (!Array.isArray(parsed)) {
|
|
106
|
+
throw new Error(`${p}: expected a JSON array of row objects.`);
|
|
107
|
+
}
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
async function fetchLiveRows(profileName, fqn, columns, rowLimit, side) {
|
|
111
|
+
const projection = columns && columns.length > 0 ? columns.join(", ") : "*";
|
|
112
|
+
const limit = rowLimit > 0 ? ` LIMIT ${Math.floor(rowLimit)}` : "";
|
|
113
|
+
const sql = `SELECT ${projection} FROM ${fqn}${limit}`;
|
|
114
|
+
const profile = await getProfile(profileName);
|
|
115
|
+
const conn = new SnowflakeConnection(profile);
|
|
116
|
+
logger.step(
|
|
117
|
+
`data-compare (${side}): connecting to ${profile.account} as ${profile.auth.username}\u2026`
|
|
118
|
+
);
|
|
119
|
+
await conn.connect();
|
|
120
|
+
try {
|
|
121
|
+
const rows = await conn.executeRows(sql);
|
|
122
|
+
logger.info(
|
|
123
|
+
`data-compare (${side}): fetched ${rows.length} row(s) from ${fqn}${rowLimit > 0 && rows.length === rowLimit ? " (LIMIT reached \u2014 increase --row-limit if more rows are needed)" : ""}.`
|
|
124
|
+
);
|
|
125
|
+
return rows;
|
|
126
|
+
} finally {
|
|
127
|
+
await conn.disconnect().catch(() => void 0);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
dataCompareCommand,
|
|
132
|
+
runDataCompare
|
|
133
|
+
};
|
|
134
|
+
//# sourceMappingURL=data-compare-UK2UXAS3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/commands/data-compare.ts"],"sourcesContent":["/**\n * `sdt data-compare` — Tier-1 SSDT-gap row-level data compare.\n *\n * Reads source + target rows from either:\n * - JSON files (`--source` / `--target`), or\n * - live warehouses (`--source-live <profile>` / `--target-live <profile>`)\n * via `SnowflakeConnection.executeRows`.\n *\n * Live mode runs `SELECT <columns> FROM <fqn>` against each side and\n * pipes rows through the dialect-aware `dataCompare.diffRows` substrate.\n *\n * Usage (JSON):\n * sdt data-compare --table DB.S.T --key ID \\\n * --source rows-source.json --target rows-target.json\n *\n * Usage (live):\n * sdt data-compare --table DB.S.T --key ID \\\n * --source-live dev --target-live prod \\\n * [--columns ID,EMAIL,STATUS] [--row-limit 50000]\n *\n * Mirrors `ddt data-compare`.\n */\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { Command } from 'commander';\nimport { dataCompare } from '@sdt-tools/core';\nimport { getProfile, SnowflakeConnection } from '@sdt-tools/core/connection';\nimport { logger } from '../util/logger.js';\n\nexport function dataCompareCommand(): Command {\n const cmd = new Command('data-compare');\n cmd\n .description(\n 'Row-level data compare: diff two tables by primary key and emit an INSERT/UPDATE/DELETE script ' +\n 'that converts target into source. Source/target rows read from JSON files in v1.',\n )\n .requiredOption(\n '--table <fqn>',\n 'Fully-qualified table name to embed in the emitted script (DATABASE.SCHEMA.TABLE).',\n )\n .requiredOption('--key <cols>', 'Comma-separated primary-key columns.')\n .option(\n '--source <path>',\n 'JSON file containing the source-side rows (TableRow[]). Mutually exclusive with --source-live.',\n )\n .option(\n '--target <path>',\n 'JSON file containing the target-side rows (TableRow[]). Mutually exclusive with --target-live.',\n )\n .option(\n '--source-live <profile>',\n 'Connection profile to read source rows from live via SELECT * FROM --table. Mutually exclusive with --source.',\n )\n .option(\n '--target-live <profile>',\n 'Connection profile to read target rows from live via SELECT * FROM --table. Mutually exclusive with --target.',\n )\n .option(\n '--row-limit <n>',\n 'Cap rows fetched per side in live mode. Default 100000. Use 0 for unbounded (DANGER on prod tables).',\n '100000',\n )\n .option(\n '--columns <cols>',\n 'Comma-separated column list to consider. Defaults to all keys seen in the rows.',\n )\n .option(\n '--max-changes <n>',\n 'Cap the changed-row count (added/removed are not capped). Default 10000.',\n '10000',\n )\n .option(\n '--case-sensitive',\n 'Compare column names case-sensitively (default: false — Snowflake idiom).',\n false,\n )\n .option('--no-transaction', 'Omit BEGIN/COMMIT wrappers from the emitted script.')\n .option('--no-header', 'Omit the REVIEW header from the emitted script.')\n .option('--format <fmt>', 'Output format: sql | json. Default sql.', 'sql')\n .option('-o, --output <path>', 'Write output to a file instead of stdout.')\n .action(async (opts) => {\n await runDataCompare(opts, 'snowflake');\n });\n return cmd;\n}\n\nexport async function runDataCompare(\n opts: Record<string, unknown>,\n dialect: 'snowflake' | 'databricks',\n): Promise<void> {\n const fqn = String(opts.table);\n const keyColumns = String(opts.key)\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n if (keyColumns.length === 0) {\n throw new Error('--key must include at least one column.');\n }\n const columns = opts.columns\n ? String(opts.columns)\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean)\n : undefined;\n\n const sourceJson = opts.source as string | undefined;\n const targetJson = opts.target as string | undefined;\n const sourceLive = opts.sourceLive as string | undefined;\n const targetLive = opts.targetLive as string | undefined;\n if (Boolean(sourceJson) === Boolean(sourceLive)) {\n throw new Error('Specify exactly one of --source or --source-live.');\n }\n if (Boolean(targetJson) === Boolean(targetLive)) {\n throw new Error('Specify exactly one of --target or --target-live.');\n }\n const rowLimit = Number(opts.rowLimit ?? '100000');\n\n const [sourceRows, targetRows] = await Promise.all([\n sourceLive\n ? fetchLiveRows(sourceLive, fqn, columns, rowLimit, 'source')\n : readRowsFile(String(sourceJson)),\n targetLive\n ? fetchLiveRows(targetLive, fqn, columns, rowLimit, 'target')\n : readRowsFile(String(targetJson)),\n ]);\n\n const result = dataCompare.diffRows(\n {\n fqn,\n keyColumns,\n ...(columns ? { columns } : {}),\n sourceRows,\n targetRows,\n },\n {\n maxChanges: Number(opts.maxChanges ?? '10000') || 10_000,\n caseSensitiveColumns: opts.caseSensitive === true,\n },\n );\n\n const format = String(opts.format).toLowerCase();\n const out =\n format === 'json'\n ? JSON.stringify(result, null, 2)\n : dataCompare.renderDataCompareScript(result, dialect, {\n transactional: opts.transaction !== false,\n includeHeader: opts.header !== false,\n });\n\n if (opts.output) {\n const outPath = path.resolve(String(opts.output));\n await fs.writeFile(outPath, out, 'utf8');\n logger.info(\n `data-compare: wrote ${outPath} (added=${result.diff.added.length} ` +\n `removed=${result.diff.removed.length} changed=${result.diff.changed.length} ` +\n `unchanged=${result.diff.unchanged}${result.truncated ? ' truncated' : ''})`,\n );\n } else {\n process.stdout.write(out);\n if (format !== 'json') process.stdout.write('\\n');\n }\n}\n\nasync function readRowsFile(p: string): Promise<dataCompare.TableRow[]> {\n const raw = await fs.readFile(path.resolve(p), 'utf8');\n const parsed = JSON.parse(raw) as unknown;\n if (!Array.isArray(parsed)) {\n throw new Error(`${p}: expected a JSON array of row objects.`);\n }\n return parsed as dataCompare.TableRow[];\n}\n\nasync function fetchLiveRows(\n profileName: string,\n fqn: string,\n columns: readonly string[] | undefined,\n rowLimit: number,\n side: 'source' | 'target',\n): Promise<dataCompare.TableRow[]> {\n const projection = columns && columns.length > 0 ? columns.join(', ') : '*';\n const limit = rowLimit > 0 ? ` LIMIT ${Math.floor(rowLimit)}` : '';\n const sql = `SELECT ${projection} FROM ${fqn}${limit}`;\n const profile = await getProfile(profileName);\n const conn = new SnowflakeConnection(profile);\n logger.step(\n `data-compare (${side}): connecting to ${profile.account} as ${profile.auth.username}…`,\n );\n await conn.connect();\n try {\n const rows = await conn.executeRows(sql);\n logger.info(\n `data-compare (${side}): fetched ${rows.length} row(s) from ${fqn}${rowLimit > 0 && rows.length === rowLimit ? ' (LIMIT reached — increase --row-limit if more rows are needed)' : ''}.`,\n );\n return rows as dataCompare.TableRow[];\n } finally {\n await conn.disconnect().catch(() => undefined);\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import "./chunk-DGUM43GV.js";
|
|
2
|
+
|
|
3
|
+
// src/commands/data-fit.ts
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { CompareEngine, PacSource } from "@sdt-tools/core/compare";
|
|
8
|
+
import { classifyTypeChange, dataFitProbeSql } from "@sdt-tools/core/types";
|
|
9
|
+
function getColumns(obj) {
|
|
10
|
+
if (!("columns" in obj)) return void 0;
|
|
11
|
+
const cols = obj.columns;
|
|
12
|
+
if (!Array.isArray(cols)) return void 0;
|
|
13
|
+
return cols;
|
|
14
|
+
}
|
|
15
|
+
function dataTypeToString(dt) {
|
|
16
|
+
if (typeof dt === "string") return dt;
|
|
17
|
+
if (dt && typeof dt === "object") {
|
|
18
|
+
const d = dt;
|
|
19
|
+
if (d.raw) return d.raw;
|
|
20
|
+
if (!d.base) return JSON.stringify(dt);
|
|
21
|
+
if (d.precision !== void 0 && d.scale !== void 0) {
|
|
22
|
+
return `${d.base}(${d.precision},${d.scale})`;
|
|
23
|
+
}
|
|
24
|
+
if (d.length !== void 0) return `${d.base}(${d.length})`;
|
|
25
|
+
return d.base;
|
|
26
|
+
}
|
|
27
|
+
return String(dt ?? "");
|
|
28
|
+
}
|
|
29
|
+
function quotedFqn(obj) {
|
|
30
|
+
const fqn = obj.fqn;
|
|
31
|
+
if (!fqn) return "<unknown>";
|
|
32
|
+
return [fqn.database, fqn.schema, fqn.name].filter(Boolean).map((p) => `"${p}"`).join(".");
|
|
33
|
+
}
|
|
34
|
+
function quotedCol(name) {
|
|
35
|
+
return `"${name}"`;
|
|
36
|
+
}
|
|
37
|
+
function dataFitCommand() {
|
|
38
|
+
const cmd = new Command("data-fit");
|
|
39
|
+
cmd.description(
|
|
40
|
+
"Emit pre-flight SELECT count_if() probes for every narrowing type change in a pac\u2194pac compare. Run them against the live target before --apply."
|
|
41
|
+
).requiredOption("--source <path>", ".sdtpac with the desired state.").requiredOption("--target <path>", ".sdtpac with the current state.").option("-o, --out <path>", "Output SQL file. Default: stdout.").option("--format <fmt>", "Output format: sql | json. Default: sql.", "sql").action(async (opts) => {
|
|
42
|
+
const source = new PacSource(path.resolve(String(opts.source)));
|
|
43
|
+
const target = new PacSource(path.resolve(String(opts.target)));
|
|
44
|
+
const engine = new CompareEngine();
|
|
45
|
+
const result = await engine.compare(source, target);
|
|
46
|
+
const probes = [];
|
|
47
|
+
for (const obj of result.objects) {
|
|
48
|
+
if (obj.kind !== "modified" || !obj.source || !obj.target) continue;
|
|
49
|
+
const srcCols = getColumns(obj.source);
|
|
50
|
+
const tgtCols = getColumns(obj.target);
|
|
51
|
+
if (!srcCols || !tgtCols) continue;
|
|
52
|
+
const byName = /* @__PURE__ */ new Map();
|
|
53
|
+
for (const c of srcCols) byName.set(c.name.toUpperCase(), { source: c, target: c });
|
|
54
|
+
for (const c of tgtCols) {
|
|
55
|
+
const slot = byName.get(c.name.toUpperCase());
|
|
56
|
+
if (slot) slot.target = c;
|
|
57
|
+
}
|
|
58
|
+
for (const [, pair] of byName) {
|
|
59
|
+
if (pair.source === pair.target) continue;
|
|
60
|
+
const fromStr = dataTypeToString(pair.target.dataType);
|
|
61
|
+
const toStr = dataTypeToString(pair.source.dataType);
|
|
62
|
+
if (fromStr === toStr) continue;
|
|
63
|
+
const check = classifyTypeChange(fromStr, toStr);
|
|
64
|
+
if (check.verdict !== "data-fit-required") continue;
|
|
65
|
+
const fqn = quotedFqn(obj.source);
|
|
66
|
+
const colName = pair.source.name;
|
|
67
|
+
const probeSql = dataFitProbeSql(check, fqn, quotedCol(colName));
|
|
68
|
+
if (!probeSql) continue;
|
|
69
|
+
probes.push({
|
|
70
|
+
fqn,
|
|
71
|
+
column: colName,
|
|
72
|
+
from: fromStr,
|
|
73
|
+
to: toStr,
|
|
74
|
+
reason: check.reason,
|
|
75
|
+
sql: probeSql
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (opts.format === "json") {
|
|
80
|
+
const out = JSON.stringify({ probeCount: probes.length, probes }, null, 2);
|
|
81
|
+
await emit(out, opts.out);
|
|
82
|
+
} else {
|
|
83
|
+
const lines = [];
|
|
84
|
+
lines.push(`-- Generated by \`sdt data-fit\` at ${(/* @__PURE__ */ new Date()).toISOString()}.`);
|
|
85
|
+
lines.push(`-- ${probes.length} narrowing type change(s) need a pre-flight check.`);
|
|
86
|
+
lines.push(`-- Run each query against the target; WOULD_FAIL must be 0 before --apply.`);
|
|
87
|
+
lines.push("");
|
|
88
|
+
for (const p of probes) {
|
|
89
|
+
lines.push(`-- PROBE: ${p.fqn}.${quotedCol(p.column)} (${p.from} \u2192 ${p.to})`);
|
|
90
|
+
lines.push(`-- Reason: ${p.reason}`);
|
|
91
|
+
lines.push(`${p.sql};`);
|
|
92
|
+
lines.push("");
|
|
93
|
+
}
|
|
94
|
+
if (probes.length === 0) {
|
|
95
|
+
lines.push(
|
|
96
|
+
"-- No narrowing type changes detected. Compare result has only safe or destructive changes;"
|
|
97
|
+
);
|
|
98
|
+
lines.push("-- data-fit probes do not apply.");
|
|
99
|
+
}
|
|
100
|
+
await emit(lines.join("\n"), opts.out);
|
|
101
|
+
}
|
|
102
|
+
if (probes.length === 0) {
|
|
103
|
+
console.error("No narrowing type changes detected \u2014 nothing to probe.");
|
|
104
|
+
} else {
|
|
105
|
+
console.error(
|
|
106
|
+
`Generated ${probes.length} data-fit probe(s). Run each against the target and confirm WOULD_FAIL = 0 before \`sdt publish --apply\`.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return cmd;
|
|
111
|
+
}
|
|
112
|
+
async function emit(text, out) {
|
|
113
|
+
if (out) {
|
|
114
|
+
const p = path.resolve(String(out));
|
|
115
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
116
|
+
await fs.writeFile(p, text + (text.endsWith("\n") ? "" : "\n"), "utf8");
|
|
117
|
+
console.error(`Wrote ${p}.`);
|
|
118
|
+
} else {
|
|
119
|
+
process.stdout.write(text + (text.endsWith("\n") ? "" : "\n"));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export {
|
|
123
|
+
dataFitCommand
|
|
124
|
+
};
|
|
125
|
+
//# sourceMappingURL=data-fit-Q45ENBRL.js.map
|