proschi 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/dist/cli.cjs +2425 -87
- package/dist/render.cjs +226 -194
- package/dist/server.cjs +2440 -165
- package/grammar/proschi.tmLanguage.json +122 -0
- package/package.json +1 -1
- package/schema/proschi-diagram.schema.json +858 -0
package/dist/server.cjs
CHANGED
|
@@ -1218,15 +1218,15 @@ var require_sharedArrayCancellation = __commonJS({
|
|
|
1218
1218
|
constructor() {
|
|
1219
1219
|
this.buffers = /* @__PURE__ */ new Map();
|
|
1220
1220
|
}
|
|
1221
|
-
enableCancellation(
|
|
1222
|
-
if (
|
|
1221
|
+
enableCancellation(request2) {
|
|
1222
|
+
if (request2.id === null) {
|
|
1223
1223
|
return;
|
|
1224
1224
|
}
|
|
1225
1225
|
const buffer = new SharedArrayBuffer(4);
|
|
1226
1226
|
const data = new Int32Array(buffer, 0, 1);
|
|
1227
1227
|
data[0] = CancellationState.Continue;
|
|
1228
|
-
this.buffers.set(
|
|
1229
|
-
|
|
1228
|
+
this.buffers.set(request2.id, buffer);
|
|
1229
|
+
request2.$cancellationData = buffer;
|
|
1230
1230
|
}
|
|
1231
1231
|
async sendCancellation(_conn, id) {
|
|
1232
1232
|
const buffer = this.buffers.get(id);
|
|
@@ -1268,8 +1268,8 @@ var require_sharedArrayCancellation = __commonJS({
|
|
|
1268
1268
|
};
|
|
1269
1269
|
var SharedArrayReceiverStrategy = class {
|
|
1270
1270
|
kind = "request";
|
|
1271
|
-
createCancellationTokenSource(
|
|
1272
|
-
const buffer =
|
|
1271
|
+
createCancellationTokenSource(request2) {
|
|
1272
|
+
const buffer = request2.$cancellationData;
|
|
1273
1273
|
if (buffer === void 0) {
|
|
1274
1274
|
return new cancellation_1.CancellationTokenSource();
|
|
1275
1275
|
}
|
|
@@ -2326,13 +2326,13 @@ var require_connection = __commonJS({
|
|
|
2326
2326
|
const toCancel = messageQueue.get(key);
|
|
2327
2327
|
if (messages_1.Message.isRequest(toCancel)) {
|
|
2328
2328
|
const strategy = options?.connectionStrategy;
|
|
2329
|
-
const
|
|
2330
|
-
if (
|
|
2329
|
+
const response2 = strategy && strategy.cancelUndispatched ? strategy.cancelUndispatched(toCancel, cancelUndispatched) : cancelUndispatched(toCancel);
|
|
2330
|
+
if (response2 && (response2.error !== void 0 || response2.result !== void 0)) {
|
|
2331
2331
|
messageQueue.delete(key);
|
|
2332
2332
|
requestTokens.delete(cancelId);
|
|
2333
|
-
|
|
2334
|
-
traceSendingResponse(
|
|
2335
|
-
messageWriter.write(
|
|
2333
|
+
response2.id = toCancel.id;
|
|
2334
|
+
traceSendingResponse(response2, message.method, Date.now());
|
|
2335
|
+
messageWriter.write(response2).catch(() => logger.error(`Sending response for canceled message failed.`));
|
|
2336
2336
|
return;
|
|
2337
2337
|
}
|
|
2338
2338
|
}
|
|
@@ -9849,8 +9849,8 @@ var require_main3 = __commonJS({
|
|
|
9849
9849
|
return args.map((arg) => typeof arg === "string" ? arg : (0, node_util_1.inspect)(arg)).join(" ");
|
|
9850
9850
|
}
|
|
9851
9851
|
const counters = /* @__PURE__ */ new Map();
|
|
9852
|
-
console.assert = function assert(
|
|
9853
|
-
if (
|
|
9852
|
+
console.assert = function assert(assertion2, ...args) {
|
|
9853
|
+
if (assertion2) {
|
|
9854
9854
|
return;
|
|
9855
9855
|
}
|
|
9856
9856
|
if (args.length === 0) {
|
|
@@ -12666,11 +12666,11 @@ var require_validate = __commonJS({
|
|
|
12666
12666
|
jsonPointer = $data;
|
|
12667
12667
|
data = names_1.default.rootData;
|
|
12668
12668
|
} else {
|
|
12669
|
-
const
|
|
12670
|
-
if (!
|
|
12669
|
+
const matches2 = RELATIVE_JSON_POINTER.exec($data);
|
|
12670
|
+
if (!matches2)
|
|
12671
12671
|
throw new Error(`Invalid JSON-pointer: ${$data}`);
|
|
12672
|
-
const up = +
|
|
12673
|
-
jsonPointer =
|
|
12672
|
+
const up = +matches2[1];
|
|
12673
|
+
jsonPointer = matches2[2];
|
|
12674
12674
|
if (jsonPointer === "#") {
|
|
12675
12675
|
if (up >= dataLevel)
|
|
12676
12676
|
throw new Error(errorMsg("property/index", up));
|
|
@@ -13557,11 +13557,11 @@ var require_schemes = __commonJS({
|
|
|
13557
13557
|
urnComponent.error = "URN can not be parsed";
|
|
13558
13558
|
return urnComponent;
|
|
13559
13559
|
}
|
|
13560
|
-
const
|
|
13561
|
-
if (
|
|
13560
|
+
const matches2 = urnComponent.path.match(URN_REG);
|
|
13561
|
+
if (matches2 && matches2[0] === urnComponent.path) {
|
|
13562
13562
|
const scheme = options.scheme || urnComponent.scheme || "urn";
|
|
13563
|
-
urnComponent.nid =
|
|
13564
|
-
urnComponent.nss =
|
|
13563
|
+
urnComponent.nid = matches2[1].toLowerCase();
|
|
13564
|
+
urnComponent.nss = matches2[2];
|
|
13565
13565
|
const urnScheme = `${scheme}:${options.nid || urnComponent.nid}`;
|
|
13566
13566
|
const schemeHandler = getSchemeHandler(urnScheme);
|
|
13567
13567
|
urnComponent.path = void 0;
|
|
@@ -13875,8 +13875,8 @@ var require_fast_uri = __commonJS({
|
|
|
13875
13875
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
13876
13876
|
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
13877
13877
|
var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
|
|
13878
|
-
function getParseError(parsed,
|
|
13879
|
-
if (
|
|
13878
|
+
function getParseError(parsed, matches2) {
|
|
13879
|
+
if (matches2[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
13880
13880
|
return 'URI path must start with "/" when authority is present.';
|
|
13881
13881
|
}
|
|
13882
13882
|
if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
|
|
@@ -13898,9 +13898,9 @@ var require_fast_uri = __commonJS({
|
|
|
13898
13898
|
function isIPLiteral(host) {
|
|
13899
13899
|
return host[0] === "[" && host[host.length - 1] === "]";
|
|
13900
13900
|
}
|
|
13901
|
-
function hasMalformedComponentPercentEncoding(
|
|
13902
|
-
const host =
|
|
13903
|
-
return hasMalformedPercentEncoding(
|
|
13901
|
+
function hasMalformedComponentPercentEncoding(matches2) {
|
|
13902
|
+
const host = matches2[4];
|
|
13903
|
+
return hasMalformedPercentEncoding(matches2[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches2[6]) || hasMalformedPercentEncoding(matches2[7]) || hasMalformedPercentEncoding(matches2[8]);
|
|
13904
13904
|
}
|
|
13905
13905
|
function canonicalizeHost(parsed, options, schemeHandler, isIP) {
|
|
13906
13906
|
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && !isIPLiteral(parsed.host) && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
|
|
@@ -13957,15 +13957,15 @@ var require_fast_uri = __commonJS({
|
|
|
13957
13957
|
}
|
|
13958
13958
|
}
|
|
13959
13959
|
}
|
|
13960
|
-
const
|
|
13961
|
-
if (
|
|
13962
|
-
parsed.scheme =
|
|
13963
|
-
parsed.userinfo =
|
|
13964
|
-
parsed.host =
|
|
13965
|
-
parsed.port = parseInt(
|
|
13966
|
-
parsed.path =
|
|
13967
|
-
parsed.query =
|
|
13968
|
-
parsed.fragment =
|
|
13960
|
+
const matches2 = uri.match(URI_PARSE);
|
|
13961
|
+
if (matches2) {
|
|
13962
|
+
parsed.scheme = matches2[1];
|
|
13963
|
+
parsed.userinfo = matches2[3];
|
|
13964
|
+
parsed.host = matches2[4];
|
|
13965
|
+
parsed.port = parseInt(matches2[5], 10);
|
|
13966
|
+
parsed.path = matches2[6] || "";
|
|
13967
|
+
parsed.query = matches2[7];
|
|
13968
|
+
parsed.fragment = matches2[8];
|
|
13969
13969
|
if (parsed.scheme !== void 0) {
|
|
13970
13970
|
const decodedScheme = unescape(parsed.scheme);
|
|
13971
13971
|
if (VALID_SCHEME.test(decodedScheme)) {
|
|
@@ -13975,14 +13975,14 @@ var require_fast_uri = __commonJS({
|
|
|
13975
13975
|
malformedScheme = true;
|
|
13976
13976
|
}
|
|
13977
13977
|
}
|
|
13978
|
-
malformedPercentEncoding = hasMalformedComponentPercentEncoding(
|
|
13978
|
+
malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches2);
|
|
13979
13979
|
if (malformedPercentEncoding) {
|
|
13980
13980
|
parsed.error = parsed.error || "URI contains malformed percent-encoding.";
|
|
13981
13981
|
}
|
|
13982
13982
|
if (isNaN(parsed.port)) {
|
|
13983
|
-
parsed.port =
|
|
13983
|
+
parsed.port = matches2[5];
|
|
13984
13984
|
}
|
|
13985
|
-
const parseError = getParseError(parsed,
|
|
13985
|
+
const parseError = getParseError(parsed, matches2);
|
|
13986
13986
|
if (parseError !== void 0) {
|
|
13987
13987
|
parsed.error = parsed.error || parseError;
|
|
13988
13988
|
malformedAuthorityOrPort = true;
|
|
@@ -14690,13 +14690,13 @@ var require_core = __commonJS({
|
|
|
14690
14690
|
RULES.all[keyword] = rule;
|
|
14691
14691
|
(_a = definition.implements) === null || _a === void 0 ? void 0 : _a.forEach((kwd) => this.addKeyword(kwd));
|
|
14692
14692
|
}
|
|
14693
|
-
function addBeforeRule(ruleGroup, rule,
|
|
14694
|
-
const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword ===
|
|
14693
|
+
function addBeforeRule(ruleGroup, rule, before2) {
|
|
14694
|
+
const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before2);
|
|
14695
14695
|
if (i >= 0) {
|
|
14696
14696
|
ruleGroup.rules.splice(i, 0, rule);
|
|
14697
14697
|
} else {
|
|
14698
14698
|
ruleGroup.rules.push(rule);
|
|
14699
|
-
this.logger.warn(`rule ${
|
|
14699
|
+
this.logger.warn(`rule ${before2} is not defined`);
|
|
14700
14700
|
}
|
|
14701
14701
|
}
|
|
14702
14702
|
function keywordMetaschema(def) {
|
|
@@ -22223,11 +22223,11 @@ var require_compose_scalar = __commonJS({
|
|
|
22223
22223
|
var require_util_empty_scalar_position = __commonJS({
|
|
22224
22224
|
"node_modules/yaml/dist/compose/util-empty-scalar-position.js"(exports2) {
|
|
22225
22225
|
"use strict";
|
|
22226
|
-
function emptyScalarPosition(offset,
|
|
22227
|
-
if (
|
|
22228
|
-
pos ?? (pos =
|
|
22226
|
+
function emptyScalarPosition(offset, before2, pos) {
|
|
22227
|
+
if (before2) {
|
|
22228
|
+
pos ?? (pos = before2.length);
|
|
22229
22229
|
for (let i = pos - 1; i >= 0; --i) {
|
|
22230
|
-
let st =
|
|
22230
|
+
let st = before2[i];
|
|
22231
22231
|
switch (st.type) {
|
|
22232
22232
|
case "space":
|
|
22233
22233
|
case "comment":
|
|
@@ -22235,10 +22235,10 @@ var require_util_empty_scalar_position = __commonJS({
|
|
|
22235
22235
|
offset -= st.source.length;
|
|
22236
22236
|
continue;
|
|
22237
22237
|
}
|
|
22238
|
-
st =
|
|
22238
|
+
st = before2[++i];
|
|
22239
22239
|
while (st?.type === "space") {
|
|
22240
22240
|
offset += st.source.length;
|
|
22241
|
-
st =
|
|
22241
|
+
st = before2[++i];
|
|
22242
22242
|
}
|
|
22243
22243
|
break;
|
|
22244
22244
|
}
|
|
@@ -22316,10 +22316,10 @@ var require_compose_node = __commonJS({
|
|
|
22316
22316
|
node.srcToken = token;
|
|
22317
22317
|
return node;
|
|
22318
22318
|
}
|
|
22319
|
-
function composeEmptyNode(ctx, offset,
|
|
22319
|
+
function composeEmptyNode(ctx, offset, before2, pos, { spaceBefore, comment, anchor, tag, end }, onError) {
|
|
22320
22320
|
const token = {
|
|
22321
22321
|
type: "scalar",
|
|
22322
|
-
offset: utilEmptyScalarPosition.emptyScalarPosition(offset,
|
|
22322
|
+
offset: utilEmptyScalarPosition.emptyScalarPosition(offset, before2, pos),
|
|
22323
22323
|
indent: -1,
|
|
22324
22324
|
source: ""
|
|
22325
22325
|
};
|
|
@@ -23334,9 +23334,9 @@ var require_lexer = __commonJS({
|
|
|
23334
23334
|
}
|
|
23335
23335
|
}
|
|
23336
23336
|
*parseQuotedScalar() {
|
|
23337
|
-
const
|
|
23338
|
-
let end = this.buffer.indexOf(
|
|
23339
|
-
if (
|
|
23337
|
+
const quote2 = this.charAt(0);
|
|
23338
|
+
let end = this.buffer.indexOf(quote2, this.pos + 1);
|
|
23339
|
+
if (quote2 === "'") {
|
|
23340
23340
|
while (end !== -1 && this.buffer[end + 1] === "'")
|
|
23341
23341
|
end = this.buffer.indexOf("'", end + 2);
|
|
23342
23342
|
} else {
|
|
@@ -25048,8 +25048,8 @@ var OpenApiSpec = class {
|
|
|
25048
25048
|
return requestBody && this.validateContent(requestBody.pointer, body);
|
|
25049
25049
|
}
|
|
25050
25050
|
validateResponse(op, key, body) {
|
|
25051
|
-
const
|
|
25052
|
-
return
|
|
25051
|
+
const response2 = this.resolve(`${op.pointer}/responses/${esc(key)}`);
|
|
25052
|
+
return response2 && this.validateContent(response2.pointer, body);
|
|
25053
25053
|
}
|
|
25054
25054
|
/** Validates against the `application/json` (or `+json`) schema of a request body or response object. */
|
|
25055
25055
|
validateContent(pointer, body) {
|
|
@@ -25204,9 +25204,9 @@ function checkStep(spec, step, warn) {
|
|
|
25204
25204
|
}
|
|
25205
25205
|
if (step.failed) return;
|
|
25206
25206
|
const label = `${op.method} ${op.path}`;
|
|
25207
|
-
const
|
|
25208
|
-
if (
|
|
25209
|
-
const errors = spec.validateRequest(op,
|
|
25207
|
+
const request2 = jsonBody(step.requestFormat, step.requestBody);
|
|
25208
|
+
if (request2 !== void 0) {
|
|
25209
|
+
const errors = spec.validateRequest(op, request2.value);
|
|
25210
25210
|
if (errors?.length) warn(`Request body does not match the schema of ${label}: ${summarize(errors)}`, step.loc);
|
|
25211
25211
|
}
|
|
25212
25212
|
if (step.statusCode === void 0) return;
|
|
@@ -25217,9 +25217,9 @@ function checkStep(spec, step, warn) {
|
|
|
25217
25217
|
warn(`Status ${step.statusCode} is not documented for ${label} (documented: ${documented.join(", ") || "none"})`, responseLoc);
|
|
25218
25218
|
return;
|
|
25219
25219
|
}
|
|
25220
|
-
const
|
|
25221
|
-
if (
|
|
25222
|
-
const errors = spec.validateResponse(op, key,
|
|
25220
|
+
const response2 = jsonBody(step.responseFormat, step.responseBody);
|
|
25221
|
+
if (response2 !== void 0) {
|
|
25222
|
+
const errors = spec.validateResponse(op, key, response2.value);
|
|
25223
25223
|
if (errors?.length) warn(`${step.statusCode} response body does not match the schema of ${label}: ${summarize(errors)}`, responseLoc);
|
|
25224
25224
|
}
|
|
25225
25225
|
}
|
|
@@ -25434,6 +25434,8 @@ var IDENT_START = /[A-Za-z_]/;
|
|
|
25434
25434
|
var IDENT_PART = /[A-Za-z0-9_]/;
|
|
25435
25435
|
var TEAM_PART = /[A-Za-z0-9_-]/;
|
|
25436
25436
|
var DIGIT = /[0-9]/;
|
|
25437
|
+
var LETTER = /[A-Za-z]/;
|
|
25438
|
+
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month|usd\/GB|MB\/s|GB\/s)(?![A-Za-z0-9_/])/;
|
|
25437
25439
|
function tokenizeLine(text, line) {
|
|
25438
25440
|
const tokens = [];
|
|
25439
25441
|
const diagnostics = [];
|
|
@@ -25487,8 +25489,16 @@ function tokenizeLine(text, line) {
|
|
|
25487
25489
|
}
|
|
25488
25490
|
}
|
|
25489
25491
|
if (DIGIT.test(ch)) {
|
|
25490
|
-
|
|
25491
|
-
|
|
25492
|
+
i = readQuantity(text, i);
|
|
25493
|
+
const value = text.slice(start, i).replace(/[ \t]+/g, "");
|
|
25494
|
+
const kind = /^\d+$/.test(value) ? "number" : "quantity";
|
|
25495
|
+
tokens.push({ kind, value, col: start + 1, length: i - start });
|
|
25496
|
+
continue;
|
|
25497
|
+
}
|
|
25498
|
+
if (ch === "<" || ch === ">") {
|
|
25499
|
+
const value = text[i + 1] === "=" ? ch + "=" : ch;
|
|
25500
|
+
tokens.push({ kind: "op", value, col: start + 1, length: value.length });
|
|
25501
|
+
i += value.length;
|
|
25492
25502
|
continue;
|
|
25493
25503
|
}
|
|
25494
25504
|
if (ch === "@") {
|
|
@@ -25514,6 +25524,26 @@ function tokenizeLine(text, line) {
|
|
|
25514
25524
|
}
|
|
25515
25525
|
return { tokens, diagnostics };
|
|
25516
25526
|
}
|
|
25527
|
+
function readQuantity(text, start) {
|
|
25528
|
+
let i = start;
|
|
25529
|
+
while (i < text.length && DIGIT.test(text[i])) i++;
|
|
25530
|
+
if (text[i] === "." && DIGIT.test(text[i + 1] ?? "")) {
|
|
25531
|
+
i++;
|
|
25532
|
+
while (i < text.length && DIGIT.test(text[i])) i++;
|
|
25533
|
+
}
|
|
25534
|
+
const suffixStart = i;
|
|
25535
|
+
if (text[i] === "%") return i + 1;
|
|
25536
|
+
while (i < text.length && LETTER.test(text[i])) i++;
|
|
25537
|
+
if (i > suffixStart && text[i] === "/" && LETTER.test(text[i + 1] ?? "")) {
|
|
25538
|
+
i++;
|
|
25539
|
+
while (i < text.length && LETTER.test(text[i])) i++;
|
|
25540
|
+
}
|
|
25541
|
+
if (/^[kmb]?$/.test(text.slice(suffixStart, i))) {
|
|
25542
|
+
const unit = SEPARATE_UNIT.exec(text.slice(i));
|
|
25543
|
+
if (unit) i += unit[0].length;
|
|
25544
|
+
}
|
|
25545
|
+
return i;
|
|
25546
|
+
}
|
|
25517
25547
|
function readString(text, start) {
|
|
25518
25548
|
let value = "";
|
|
25519
25549
|
let i = start + 1;
|
|
@@ -25532,15 +25562,15 @@ function readString(text, start) {
|
|
|
25532
25562
|
}
|
|
25533
25563
|
function stripTrailingComment(text) {
|
|
25534
25564
|
let depth = 0;
|
|
25535
|
-
let
|
|
25565
|
+
let quote2 = null;
|
|
25536
25566
|
for (let i = 0; i < text.length; i++) {
|
|
25537
25567
|
const ch = text[i];
|
|
25538
|
-
if (
|
|
25568
|
+
if (quote2) {
|
|
25539
25569
|
if (ch === "\\") i++;
|
|
25540
|
-
else if (ch ===
|
|
25570
|
+
else if (ch === quote2) quote2 = null;
|
|
25541
25571
|
continue;
|
|
25542
25572
|
}
|
|
25543
|
-
if (ch === '"')
|
|
25573
|
+
if (ch === '"') quote2 = ch;
|
|
25544
25574
|
else if (ch === "{" || ch === "[") depth++;
|
|
25545
25575
|
else if (ch === "}" || ch === "]") depth = Math.max(0, depth - 1);
|
|
25546
25576
|
else if (ch === "#" && depth === 0 && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i);
|
|
@@ -25596,11 +25626,169 @@ function endpointGroupKey(endpoint, all) {
|
|
|
25596
25626
|
return method ? `${method} ${templatize(path)}` : templatize(path);
|
|
25597
25627
|
}
|
|
25598
25628
|
|
|
25629
|
+
// ../frontend/src/dsl/kinds.ts
|
|
25630
|
+
var KINDS = ["client", "edge", "cdn", "loadbalancer", "gateway", "dns", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
25631
|
+
var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
|
|
25632
|
+
var EDGE_KINDS = ["edge", "cdn", "loadbalancer", "gateway", "dns"];
|
|
25633
|
+
var BY_TECH = {
|
|
25634
|
+
"AWS CloudFront": "cdn",
|
|
25635
|
+
"Azure CDN": "cdn",
|
|
25636
|
+
"GCP Cloud CDN": "cdn",
|
|
25637
|
+
"Azure Front Door": "cdn",
|
|
25638
|
+
"AWS Load Balancer": "loadbalancer",
|
|
25639
|
+
"GCP Load Balancing": "loadbalancer",
|
|
25640
|
+
"AWS API Gateway": "gateway",
|
|
25641
|
+
"Azure API Management": "gateway",
|
|
25642
|
+
"AWS Route53": "dns",
|
|
25643
|
+
"Azure DNS": "dns",
|
|
25644
|
+
"GCP Cloud DNS": "dns",
|
|
25645
|
+
Redis: "cache",
|
|
25646
|
+
Elasticsearch: "search",
|
|
25647
|
+
"GCP BigQuery": "analytics",
|
|
25648
|
+
InfluxDB: "analytics",
|
|
25649
|
+
TimescaleDB: "analytics",
|
|
25650
|
+
// A note shape annotates the diagram; it is not a client.
|
|
25651
|
+
Note: "other"
|
|
25652
|
+
};
|
|
25653
|
+
var BY_TYPE = {
|
|
25654
|
+
shape: "client",
|
|
25655
|
+
cdn: "edge",
|
|
25656
|
+
service: "service",
|
|
25657
|
+
compute: "service",
|
|
25658
|
+
container: "service",
|
|
25659
|
+
serverless: "function",
|
|
25660
|
+
cache: "cache",
|
|
25661
|
+
database: "database",
|
|
25662
|
+
queue: "queue",
|
|
25663
|
+
storage: "storage",
|
|
25664
|
+
external: "external"
|
|
25665
|
+
};
|
|
25666
|
+
function kindOf(node) {
|
|
25667
|
+
if (node.kind !== "component") return "other";
|
|
25668
|
+
return BY_TECH[node.techStack] ?? BY_TYPE[node.type] ?? "other";
|
|
25669
|
+
}
|
|
25670
|
+
function isKind(word) {
|
|
25671
|
+
return KINDS.includes(word);
|
|
25672
|
+
}
|
|
25673
|
+
function isDataStore(kind) {
|
|
25674
|
+
return DATA_STORE_KINDS.includes(kind);
|
|
25675
|
+
}
|
|
25676
|
+
function isEdge(kind) {
|
|
25677
|
+
return EDGE_KINDS.includes(kind);
|
|
25678
|
+
}
|
|
25679
|
+
function kindMatches(actual, wanted) {
|
|
25680
|
+
return actual === wanted || wanted === "edge" && isEdge(actual);
|
|
25681
|
+
}
|
|
25682
|
+
|
|
25683
|
+
// ../frontend/src/dsl/quantity.ts
|
|
25684
|
+
function parseQuantity(text) {
|
|
25685
|
+
const m = /^(\d+(?:\.\d+)?)(.*)$/.exec(text.replace(/\s+/g, ""));
|
|
25686
|
+
if (!m) return { error: `'${text}' is not a number` };
|
|
25687
|
+
const number = Number(m[1]);
|
|
25688
|
+
const suffix = m[2];
|
|
25689
|
+
const unit = UNITS.get(suffix);
|
|
25690
|
+
if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
|
|
25691
|
+
const magnitude = MAGNITUDES.get(suffix[0]);
|
|
25692
|
+
const rest = suffix.slice(1);
|
|
25693
|
+
const after2 = UNITS.get(rest);
|
|
25694
|
+
if (magnitude && (rest === "" || after2)) {
|
|
25695
|
+
return after2 ? { value: round(number * magnitude * after2.factor), unit: after2.unit } : { value: round(number * magnitude) };
|
|
25696
|
+
}
|
|
25697
|
+
return { error: `Unknown unit '${suffix}' in '${text}'; use rps, rpm, rpd, ms, s, %, usd/month, MB/s, GB/s or usd/GB (with k, m or b for thousands, millions, billions)` };
|
|
25698
|
+
}
|
|
25699
|
+
var round = (n) => Number(n.toPrecision(12));
|
|
25700
|
+
var MAGNITUDES = /* @__PURE__ */ new Map([
|
|
25701
|
+
["k", 1e3],
|
|
25702
|
+
["m", 1e6],
|
|
25703
|
+
["b", 1e9]
|
|
25704
|
+
]);
|
|
25705
|
+
var UNITS = /* @__PURE__ */ new Map([
|
|
25706
|
+
["rps", { unit: "rps", factor: 1 }],
|
|
25707
|
+
["rpm", { unit: "rps", factor: 1 / 60 }],
|
|
25708
|
+
["rpd", { unit: "rps", factor: 1 / 86400 }],
|
|
25709
|
+
["ms", { unit: "ms", factor: 1 }],
|
|
25710
|
+
["s", { unit: "ms", factor: 1e3 }],
|
|
25711
|
+
["%", { unit: "%", factor: 1 }],
|
|
25712
|
+
["usd/month", { unit: "usd/month", factor: 1 }],
|
|
25713
|
+
["MB/s", { unit: "MBps", factor: 1 }],
|
|
25714
|
+
["GB/s", { unit: "MBps", factor: 1e3 }],
|
|
25715
|
+
["usd/GB", { unit: "usd/GB", factor: 1 }]
|
|
25716
|
+
]);
|
|
25717
|
+
|
|
25718
|
+
// ../frontend/src/dsl/access.ts
|
|
25719
|
+
var WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
25720
|
+
var WRITE_VERBS = [
|
|
25721
|
+
"INSERT",
|
|
25722
|
+
"UPDATE",
|
|
25723
|
+
"UPSERT",
|
|
25724
|
+
"DELETE",
|
|
25725
|
+
"PUT",
|
|
25726
|
+
"SET",
|
|
25727
|
+
"WRITE",
|
|
25728
|
+
"APPEND",
|
|
25729
|
+
"INCR",
|
|
25730
|
+
"DECR",
|
|
25731
|
+
"LPUSH",
|
|
25732
|
+
"RPUSH",
|
|
25733
|
+
"ZADD",
|
|
25734
|
+
"HSET",
|
|
25735
|
+
"GEOADD",
|
|
25736
|
+
"PUBLISH",
|
|
25737
|
+
"SEND",
|
|
25738
|
+
"ENQUEUE",
|
|
25739
|
+
"PRODUCE",
|
|
25740
|
+
"CHARGE",
|
|
25741
|
+
"CREATE",
|
|
25742
|
+
// DynamoDB and Redis commands that store data
|
|
25743
|
+
"PUTITEM",
|
|
25744
|
+
"UPDATEITEM",
|
|
25745
|
+
"DELETEITEM",
|
|
25746
|
+
"BATCHWRITEITEM",
|
|
25747
|
+
"INCRBY",
|
|
25748
|
+
"HINCRBY",
|
|
25749
|
+
"SADD",
|
|
25750
|
+
"XADD",
|
|
25751
|
+
"MSET",
|
|
25752
|
+
// Plain words for storing something
|
|
25753
|
+
"SAVE",
|
|
25754
|
+
"STORE",
|
|
25755
|
+
"UPLOAD",
|
|
25756
|
+
"COMMIT",
|
|
25757
|
+
"RECORD",
|
|
25758
|
+
"MARK",
|
|
25759
|
+
// Claiming a resource changes state: a seat, a driver, stock
|
|
25760
|
+
"RESERVE",
|
|
25761
|
+
"HOLD",
|
|
25762
|
+
"BOOK",
|
|
25763
|
+
// Handing something on: a write at the receiver (a queue, a provider), like SEND and PUBLISH
|
|
25764
|
+
"EMIT",
|
|
25765
|
+
"NOTIFY"
|
|
25766
|
+
];
|
|
25767
|
+
function accessOf(httpMethod, label) {
|
|
25768
|
+
if (httpMethod) return WRITE_METHODS.includes(httpMethod.toUpperCase()) ? "write" : "read";
|
|
25769
|
+
const word = /^\s*"?([A-Za-z_]+)/.exec(label)?.[1];
|
|
25770
|
+
return word && WRITE_VERBS.includes(word.toUpperCase()) ? "write" : "read";
|
|
25771
|
+
}
|
|
25772
|
+
|
|
25599
25773
|
// ../frontend/src/dsl/parser.ts
|
|
25600
25774
|
var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
25601
25775
|
var DEFAULT_TECH = "Rectangle";
|
|
25602
25776
|
var DEFAULT_GROUP_TECH = "Logical Group";
|
|
25603
25777
|
var MAX_SCENARIOS = 32;
|
|
25778
|
+
var REPLICAS = /^x(\d+)$/;
|
|
25779
|
+
var PERCENTILES = { p50: 50, p90: 90, p95: 95, p99: 99, p999: 99.9 };
|
|
25780
|
+
var FIELD_FLAGS = ["key", "index", "unique", "optional"];
|
|
25781
|
+
var UNIT_EXAMPLES = {
|
|
25782
|
+
rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
|
|
25783
|
+
ms: "a duration, e.g. 50ms or 1.5s",
|
|
25784
|
+
"%": "a percentage, e.g. 99.9%",
|
|
25785
|
+
"usd/month": "a monthly cost, e.g. 400 usd/month",
|
|
25786
|
+
MBps: "a bandwidth, e.g. 500 MB/s or 1 GB/s",
|
|
25787
|
+
"usd/GB": "a price per gigabyte, e.g. 0.05 usd/GB"
|
|
25788
|
+
};
|
|
25789
|
+
var CAPACITY_PARTS = "a rate, reads, writes, shards, latency, availability, cost, durable or volatile, consistency, bandwidth or egress";
|
|
25790
|
+
var ASSERTION_HELP = 'Expected an assertion: "Use case" calls <node>, "Use case" writes <node> before responding, <node> calls <node>, no path from <node> to <node>, <node> has replicas >= 2, \u2026';
|
|
25791
|
+
var ASSERTION_VERBS = "calls, every scenario calls, never calls, never waits for, writes, responds, starts at, has scenario or handles failure of";
|
|
25604
25792
|
var techByName = new Map(
|
|
25605
25793
|
componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
|
|
25606
25794
|
);
|
|
@@ -25628,6 +25816,16 @@ var Parser = class {
|
|
|
25628
25816
|
/** `a|b` for every connection, both directions; filled before use cases are built. */
|
|
25629
25817
|
connected = /* @__PURE__ */ new Set();
|
|
25630
25818
|
title;
|
|
25819
|
+
summary;
|
|
25820
|
+
traffic = [];
|
|
25821
|
+
requirements = [];
|
|
25822
|
+
capacity = [];
|
|
25823
|
+
entities = [];
|
|
25824
|
+
decisions = [];
|
|
25825
|
+
tests = [];
|
|
25826
|
+
/** Checks of use case, scenario and node names, run once every file is read and use cases are built. */
|
|
25827
|
+
deferred = [];
|
|
25828
|
+
built = [];
|
|
25631
25829
|
options;
|
|
25632
25830
|
constructor(source2, options) {
|
|
25633
25831
|
this.options = options;
|
|
@@ -25642,11 +25840,21 @@ var Parser = class {
|
|
|
25642
25840
|
const useCases = this.useCases.map(({ useCase, body }) => this.buildUseCase(useCase, body));
|
|
25643
25841
|
const endpoints = useCases.flatMap((u) => u.endpoint ? [u.endpoint] : []);
|
|
25644
25842
|
for (const u of useCases) if (u.endpoint) u.endpointGroup = endpointGroupKey(u.endpoint, endpoints);
|
|
25843
|
+
this.built = useCases;
|
|
25844
|
+
for (const check of this.deferred) check();
|
|
25645
25845
|
const diagram = {
|
|
25646
25846
|
title: this.title,
|
|
25647
25847
|
nodes: [...this.nodes.values()],
|
|
25648
25848
|
edges: this.edges,
|
|
25649
|
-
useCases
|
|
25849
|
+
useCases,
|
|
25850
|
+
// The HLD sections are left out when a document has none, so older documents parse exactly as before.
|
|
25851
|
+
...this.summary !== void 0 ? { summary: this.summary } : {},
|
|
25852
|
+
...this.traffic.length ? { traffic: this.traffic } : {},
|
|
25853
|
+
...this.requirements.length ? { requirements: this.requirements } : {},
|
|
25854
|
+
...this.capacity.length ? { capacity: this.capacity } : {},
|
|
25855
|
+
...this.entities.length ? { entities: this.entities } : {},
|
|
25856
|
+
...this.decisions.length ? { decisions: this.decisions } : {},
|
|
25857
|
+
...this.tests.length ? { tests: this.tests } : {}
|
|
25650
25858
|
};
|
|
25651
25859
|
const order = (d) => this.fileOrder.get(d.file) ?? 0;
|
|
25652
25860
|
this.diagnostics.sort((a, b) => order(a) - order(b) || a.line - b.line || a.col - b.col);
|
|
@@ -25658,7 +25866,7 @@ var Parser = class {
|
|
|
25658
25866
|
i = this.parseLine(i);
|
|
25659
25867
|
}
|
|
25660
25868
|
for (const frame of this.stack) {
|
|
25661
|
-
const what = frame.kind === "group" ? `group '${frame.id}'` : frame.kind === "usecase" ? `usecase '${frame.useCase.name}'` : frame.kind === "alt" ? `alt '${frame.name}'` : "par block";
|
|
25869
|
+
const what = frame.kind === "group" ? `group '${frame.id}'` : frame.kind === "usecase" ? `usecase '${frame.useCase.name}'` : frame.kind === "alt" ? `alt '${frame.name}'` : frame.kind === "par" ? "par block" : sectionName(frame);
|
|
25662
25870
|
this.error(`Missing } to close ${what}`, frame.loc);
|
|
25663
25871
|
}
|
|
25664
25872
|
}
|
|
@@ -25668,8 +25876,19 @@ var Parser = class {
|
|
|
25668
25876
|
const { tokens, diagnostics } = tokenizeLine(this.lines[index], line);
|
|
25669
25877
|
this.diagnostics.push(...diagnostics.map((d) => ({ ...d, ...this.fileField() })));
|
|
25670
25878
|
if (tokens.length === 0 || diagnostics.some((d) => d.severity === "error")) return index;
|
|
25671
|
-
const [first, second] = tokens;
|
|
25879
|
+
const [first, second, third] = tokens;
|
|
25672
25880
|
const loc = (t) => this.locOf(t, line);
|
|
25881
|
+
const top = this.top();
|
|
25882
|
+
if (top?.kind === "section") {
|
|
25883
|
+
if (first.kind === "rbrace") {
|
|
25884
|
+
this.stack.pop();
|
|
25885
|
+
if (!top.discard) this.closeSection(top);
|
|
25886
|
+
if (second) this.error("Unexpected input after }", loc(second));
|
|
25887
|
+
} else if (!top.discard) {
|
|
25888
|
+
this.parseSectionLine(top, tokens, line);
|
|
25889
|
+
}
|
|
25890
|
+
return index;
|
|
25891
|
+
}
|
|
25673
25892
|
if (first.kind === "rbrace") {
|
|
25674
25893
|
if (this.stack.length === 0) this.error("Unmatched }", loc(first));
|
|
25675
25894
|
else this.closeFrame();
|
|
@@ -25703,6 +25922,26 @@ var Parser = class {
|
|
|
25703
25922
|
case "import":
|
|
25704
25923
|
this.parseImport(tokens, line);
|
|
25705
25924
|
break;
|
|
25925
|
+
// These words are keywords only in the shape of their statement, so older documents may still use them as ids.
|
|
25926
|
+
case "traffic":
|
|
25927
|
+
case "requirements":
|
|
25928
|
+
case "capacity":
|
|
25929
|
+
if (second?.kind === "lbrace") this.openSection(first.value, tokens, line);
|
|
25930
|
+
else this.parseNode(tokens, line);
|
|
25931
|
+
break;
|
|
25932
|
+
case "entity":
|
|
25933
|
+
if (second?.kind === "ident" && second.value !== "pos" && !REPLICAS.test(second.value)) this.parseEntity(tokens, line);
|
|
25934
|
+
else this.parseNode(tokens, line);
|
|
25935
|
+
break;
|
|
25936
|
+
case "decision":
|
|
25937
|
+
if (second?.kind === "string" && (third?.kind === "lbrace" || third?.kind === "ident" && third.value !== "pos" && !REPLICAS.test(third.value)))
|
|
25938
|
+
this.parseDecision(tokens, line);
|
|
25939
|
+
else this.parseNode(tokens, line);
|
|
25940
|
+
break;
|
|
25941
|
+
case "test":
|
|
25942
|
+
if (second?.kind === "string" && tokens[tokens.length - 1].kind === "lbrace") this.parseTest(tokens, line);
|
|
25943
|
+
else this.parseNode(tokens, line);
|
|
25944
|
+
break;
|
|
25706
25945
|
default:
|
|
25707
25946
|
this.parseNode(tokens, line);
|
|
25708
25947
|
}
|
|
@@ -25714,8 +25953,12 @@ var Parser = class {
|
|
|
25714
25953
|
this.error('Expected a title, e.g. title "My System"', this.locOf(tokens[0], line));
|
|
25715
25954
|
return;
|
|
25716
25955
|
}
|
|
25717
|
-
|
|
25718
|
-
this.
|
|
25956
|
+
const summary = tokens[2]?.kind === "string" ? tokens[2] : void 0;
|
|
25957
|
+
if (this.file === void 0) {
|
|
25958
|
+
this.title = value.value;
|
|
25959
|
+
if (summary) this.summary = summary.value;
|
|
25960
|
+
}
|
|
25961
|
+
this.expectEnd(tokens, summary ? 3 : 2, line);
|
|
25719
25962
|
}
|
|
25720
25963
|
parseImport(tokens, line) {
|
|
25721
25964
|
const keyword = tokens[0];
|
|
@@ -25924,6 +26167,13 @@ var Parser = class {
|
|
|
25924
26167
|
}
|
|
25925
26168
|
node.position = { x: Number(x.value), y: Number(y.value) };
|
|
25926
26169
|
i += 3;
|
|
26170
|
+
} else if (t.kind === "ident" && REPLICAS.test(t.value)) {
|
|
26171
|
+
const replicas = Number(REPLICAS.exec(t.value)[1]);
|
|
26172
|
+
if (replicas < 1) {
|
|
26173
|
+
this.error("A node needs at least one replica: x1, x2, \u2026", this.locOf(t, line));
|
|
26174
|
+
return;
|
|
26175
|
+
}
|
|
26176
|
+
node.replicas = replicas;
|
|
25927
26177
|
} else if (t.kind === "label" || t.kind === "arrow") {
|
|
25928
26178
|
this.error(`Expected '${idToken.value} -> target' for a connection`, this.locOf(t, line));
|
|
25929
26179
|
return;
|
|
@@ -25969,7 +26219,20 @@ var Parser = class {
|
|
|
25969
26219
|
if (top?.kind === "usecase" || top?.kind === "alt" || top?.kind === "par") {
|
|
25970
26220
|
const body = this.currentBody();
|
|
25971
26221
|
body.openAlt = void 0;
|
|
25972
|
-
|
|
26222
|
+
const prefixes = arrow === "-->" ? { rest: label } : this.readLabelPrefixes(label, labelToken, line);
|
|
26223
|
+
body.items.push({
|
|
26224
|
+
kind: "step",
|
|
26225
|
+
step: {
|
|
26226
|
+
from,
|
|
26227
|
+
to,
|
|
26228
|
+
arrow,
|
|
26229
|
+
label: prefixes.rest,
|
|
26230
|
+
parallelGroup: top.kind === "par" ? top.group : void 0,
|
|
26231
|
+
...prefixes.multiplier !== void 0 ? { multiplier: prefixes.multiplier } : {},
|
|
26232
|
+
...prefixes.sizeBytes !== void 0 ? { sizeBytes: prefixes.sizeBytes } : {},
|
|
26233
|
+
loc
|
|
26234
|
+
}
|
|
26235
|
+
});
|
|
25973
26236
|
} else if (arrow === "-x") {
|
|
25974
26237
|
this.error("'-x' marks a failed call and is only allowed in use case steps", this.locOf(arrowToken, line));
|
|
25975
26238
|
} else {
|
|
@@ -25979,6 +26242,751 @@ var Parser = class {
|
|
|
25979
26242
|
}
|
|
25980
26243
|
return lastLine;
|
|
25981
26244
|
}
|
|
26245
|
+
/**
|
|
26246
|
+
* Reads `x<N>` (fan-out) and `~<size>` (payload size) at the start of a step
|
|
26247
|
+
* label, in either order (§7.3), and returns the label without them. A
|
|
26248
|
+
* prefix must be followed by whitespace or end the label, so `xml …` and
|
|
26249
|
+
* `x-request-id` are ordinary labels. Problems are reported at the prefix.
|
|
26250
|
+
*/
|
|
26251
|
+
readLabelPrefixes(label, token, line) {
|
|
26252
|
+
const out = { rest: label };
|
|
26253
|
+
if (!token) return out;
|
|
26254
|
+
const after2 = this.lines[line - 1].slice(token.col);
|
|
26255
|
+
const base = token.col + 1 + (after2.length - after2.trimStart().length);
|
|
26256
|
+
let offset = 0;
|
|
26257
|
+
for (; ; ) {
|
|
26258
|
+
const rest = label.slice(offset);
|
|
26259
|
+
const at = (length) => ({ line, col: base + offset, length, ...this.fileField() });
|
|
26260
|
+
const fanOut = /^x(\d+)(?=\s|$)/.exec(rest);
|
|
26261
|
+
const size = rest.startsWith("~") ? /^~(\S*)/.exec(rest) : null;
|
|
26262
|
+
const match = fanOut ?? size;
|
|
26263
|
+
if (!match) break;
|
|
26264
|
+
if (fanOut) {
|
|
26265
|
+
const n = Number(fanOut[1]);
|
|
26266
|
+
if (out.multiplier !== void 0) this.error("The fan-out is given twice; write one x<N>", at(match[0].length));
|
|
26267
|
+
else if (n < 1) this.error("A fan-out needs at least one call per request: x1, x2, \u2026", at(match[0].length));
|
|
26268
|
+
else out.multiplier = n;
|
|
26269
|
+
} else {
|
|
26270
|
+
const bytes = parseSize(size[1]);
|
|
26271
|
+
if (out.sizeBytes !== void 0) this.error("The payload size is given twice; write one ~<size>", at(match[0].length));
|
|
26272
|
+
else if ("error" in bytes) this.error(bytes.error, at(match[0].length));
|
|
26273
|
+
else out.sizeBytes = bytes.value;
|
|
26274
|
+
}
|
|
26275
|
+
offset += match[0].length;
|
|
26276
|
+
offset += label.slice(offset).length - label.slice(offset).trimStart().length;
|
|
26277
|
+
}
|
|
26278
|
+
out.rest = label.slice(offset);
|
|
26279
|
+
return out;
|
|
26280
|
+
}
|
|
26281
|
+
// ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
|
|
26282
|
+
/**
|
|
26283
|
+
* Starts a section block. Sections are top level only; a misplaced one, or
|
|
26284
|
+
* one whose header is broken, still opens a block so that its lines and its
|
|
26285
|
+
* `}` are not mistaken for nodes and the end of an enclosing block.
|
|
26286
|
+
*/
|
|
26287
|
+
openSection(section, tokens, line, fill = {}) {
|
|
26288
|
+
const keyword = tokens[0];
|
|
26289
|
+
const loc = this.locOf(keyword, line);
|
|
26290
|
+
if (this.stack.length > 0) {
|
|
26291
|
+
this.error(`${section} is only allowed at the top level`, loc);
|
|
26292
|
+
this.skipSection(section, tokens, line);
|
|
26293
|
+
return false;
|
|
26294
|
+
}
|
|
26295
|
+
if (section === "traffic" || section === "requirements" || section === "capacity") this.expectEnd(tokens, 2, line);
|
|
26296
|
+
this.stack.push({ kind: "section", section, ...fill, loc });
|
|
26297
|
+
return true;
|
|
26298
|
+
}
|
|
26299
|
+
/** After an error in a section header: skips the block, if the line opens one. */
|
|
26300
|
+
skipSection(section, tokens, line) {
|
|
26301
|
+
if (tokens[tokens.length - 1].kind === "lbrace") this.stack.push({ kind: "section", section, discard: true, loc: this.locOf(tokens[0], line) });
|
|
26302
|
+
}
|
|
26303
|
+
closeSection(frame) {
|
|
26304
|
+
if (frame.test && frame.test.assertions.length === 0) this.warning(`Test '${frame.test.name}' has no assertions`, frame.test.loc);
|
|
26305
|
+
}
|
|
26306
|
+
parseSectionLine(frame, tokens, line) {
|
|
26307
|
+
switch (frame.section) {
|
|
26308
|
+
case "traffic":
|
|
26309
|
+
return this.parseTrafficLine(tokens, line);
|
|
26310
|
+
case "requirements":
|
|
26311
|
+
return this.parseRequirement(tokens, line);
|
|
26312
|
+
case "capacity":
|
|
26313
|
+
return this.parseCapacityLine(tokens, line);
|
|
26314
|
+
case "entity":
|
|
26315
|
+
return this.parseField(frame.entity, tokens, line);
|
|
26316
|
+
case "decision":
|
|
26317
|
+
return this.parseDecisionLine(frame.decision, tokens, line);
|
|
26318
|
+
case "test": {
|
|
26319
|
+
const assertion2 = this.parseAssertion(tokens, line);
|
|
26320
|
+
if (assertion2) frame.test.assertions.push(assertion2);
|
|
26321
|
+
return;
|
|
26322
|
+
}
|
|
26323
|
+
}
|
|
26324
|
+
}
|
|
26325
|
+
/** `"Redirect" 100k rps mix "Cache hit" 90%, "Cache miss" 10%` */
|
|
26326
|
+
parseTrafficLine(tokens, line) {
|
|
26327
|
+
const [name, rate] = tokens;
|
|
26328
|
+
if (name.kind !== "string") {
|
|
26329
|
+
this.error('Expected a use case name in quotes and a rate, e.g. "Redirect" 100k rps', this.locOf(name, line));
|
|
26330
|
+
return;
|
|
26331
|
+
}
|
|
26332
|
+
const rps = this.quantityOf(rate, "rps", name, line);
|
|
26333
|
+
if (rps === void 0) return;
|
|
26334
|
+
const entry = { useCase: name.value, rps, loc: this.lineLoc(tokens, line) };
|
|
26335
|
+
const shares = [];
|
|
26336
|
+
let i = 2;
|
|
26337
|
+
if (tokens[i]) {
|
|
26338
|
+
const mix = tokens[i++];
|
|
26339
|
+
if (!isWord(mix, "mix")) {
|
|
26340
|
+
this.error(`Unexpected ${describe(mix)}; expected mix "Scenario" 90%, "Other" 10%`, this.locOf(mix, line));
|
|
26341
|
+
return;
|
|
26342
|
+
}
|
|
26343
|
+
for (; ; ) {
|
|
26344
|
+
const scenario = tokens[i];
|
|
26345
|
+
if (scenario?.kind !== "string") {
|
|
26346
|
+
this.error('Expected a scenario name in quotes and its share, e.g. "Cache hit" 90%', this.locOf(scenario ?? tokens[i - 1], line));
|
|
26347
|
+
return;
|
|
26348
|
+
}
|
|
26349
|
+
const share = this.quantityOf(tokens[i + 1], "%", scenario, line);
|
|
26350
|
+
if (share === void 0) return;
|
|
26351
|
+
const loc = this.locOf(scenario, line);
|
|
26352
|
+
if (shares.some((s) => s.scenario === scenario.value)) {
|
|
26353
|
+
this.error(`Scenario '${scenario.value}' is in the mix twice`, loc);
|
|
26354
|
+
return;
|
|
26355
|
+
}
|
|
26356
|
+
shares.push({ scenario: scenario.value, share, loc });
|
|
26357
|
+
i += 2;
|
|
26358
|
+
if (!tokens[i]) break;
|
|
26359
|
+
if (tokens[i].kind !== "comma") {
|
|
26360
|
+
this.error(`Expected , between mix shares, not ${describe(tokens[i])}`, this.locOf(tokens[i], line));
|
|
26361
|
+
return;
|
|
26362
|
+
}
|
|
26363
|
+
i++;
|
|
26364
|
+
}
|
|
26365
|
+
const total = shares.reduce((sum, s) => sum + s.share, 0);
|
|
26366
|
+
if (total <= 0) {
|
|
26367
|
+
this.error("Mix shares must add up to more than 0%", this.locOf(mix, line));
|
|
26368
|
+
return;
|
|
26369
|
+
}
|
|
26370
|
+
if (Math.abs(total - 100) > 1e-9) {
|
|
26371
|
+
this.warning(`Mix shares add up to ${Number(total.toFixed(3))}%, not 100%; they are scaled to fit`, this.locOf(mix, line));
|
|
26372
|
+
}
|
|
26373
|
+
entry.mix = shares.map((s) => ({ scenario: s.scenario, share: s.share / total }));
|
|
26374
|
+
}
|
|
26375
|
+
const nameLoc = this.locOf(name, line);
|
|
26376
|
+
const first = this.traffic.find((t) => t.useCase === entry.useCase);
|
|
26377
|
+
if (first) {
|
|
26378
|
+
this.error(`Duplicate traffic for '${entry.useCase}' (first ${this.where(first.loc, nameLoc)})`, nameLoc);
|
|
26379
|
+
return;
|
|
26380
|
+
}
|
|
26381
|
+
this.traffic.push(entry);
|
|
26382
|
+
this.deferred.push(() => {
|
|
26383
|
+
const useCase = this.checkUseCase(entry.useCase, nameLoc);
|
|
26384
|
+
if (useCase) for (const s of shares) this.checkScenario(useCase, s.scenario, s.loc);
|
|
26385
|
+
});
|
|
26386
|
+
}
|
|
26387
|
+
/** One line of `requirements { … }`. */
|
|
26388
|
+
parseRequirement(tokens, line) {
|
|
26389
|
+
const [first] = tokens;
|
|
26390
|
+
const loc = this.lineLoc(tokens, line);
|
|
26391
|
+
const help = 'Expected a requirement: p99 "Use case" < 50ms, availability >= 99.9%, durable "Use case", survive any node failure, cost <= 3000 usd/month';
|
|
26392
|
+
if (first.kind !== "ident") {
|
|
26393
|
+
this.error(help, this.locOf(first, line));
|
|
26394
|
+
return;
|
|
26395
|
+
}
|
|
26396
|
+
const word = first.value;
|
|
26397
|
+
let i = 1;
|
|
26398
|
+
let useCase;
|
|
26399
|
+
const readUseCase = () => {
|
|
26400
|
+
if (tokens[i]?.kind !== "string") return;
|
|
26401
|
+
useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
|
|
26402
|
+
i++;
|
|
26403
|
+
};
|
|
26404
|
+
const commit = (requirement2, scenario) => {
|
|
26405
|
+
this.requirements.push(requirement2);
|
|
26406
|
+
const named = useCase;
|
|
26407
|
+
if (named)
|
|
26408
|
+
this.deferred.push(() => {
|
|
26409
|
+
const found = this.checkUseCase(named.name, named.loc);
|
|
26410
|
+
if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
|
|
26411
|
+
});
|
|
26412
|
+
};
|
|
26413
|
+
const withUseCase = () => useCase ? { useCase: useCase.name } : {};
|
|
26414
|
+
if (/^p\d+$/.test(word)) {
|
|
26415
|
+
const percentile = PERCENTILES[word];
|
|
26416
|
+
if (percentile === void 0) {
|
|
26417
|
+
this.error(`Unknown percentile '${word}'; use p50, p90, p95, p99 or p999`, this.locOf(first, line));
|
|
26418
|
+
return;
|
|
26419
|
+
}
|
|
26420
|
+
readUseCase();
|
|
26421
|
+
let scenario;
|
|
26422
|
+
if (isWord(tokens[i], "scenario")) {
|
|
26423
|
+
const name = tokens[i + 1];
|
|
26424
|
+
if (!useCase) {
|
|
26425
|
+
this.error(`A scenario belongs to a use case; write ${word} "Use case" scenario "Scenario" < 100ms`, this.locOf(tokens[i], line));
|
|
26426
|
+
return;
|
|
26427
|
+
}
|
|
26428
|
+
if (name?.kind !== "string") {
|
|
26429
|
+
this.error("Expected a scenario name in quotes after scenario", this.locOf(name ?? tokens[i], line));
|
|
26430
|
+
return;
|
|
26431
|
+
}
|
|
26432
|
+
scenario = { name: name.value, loc: this.locOf(name, line) };
|
|
26433
|
+
i += 2;
|
|
26434
|
+
}
|
|
26435
|
+
if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
|
|
26436
|
+
const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
|
|
26437
|
+
if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
|
|
26438
|
+
commit({ kind: "latency", percentile, ...withUseCase(), ...scenario ? { scenario: scenario.name } : {}, maxMs, loc }, scenario);
|
|
26439
|
+
return;
|
|
26440
|
+
}
|
|
26441
|
+
switch (word) {
|
|
26442
|
+
case "availability": {
|
|
26443
|
+
readUseCase();
|
|
26444
|
+
if (!this.expectOp(tokens, i, [">=", ">"], 'availability "Use case" >= 99.9%', line)) return;
|
|
26445
|
+
const minPercent = this.quantityOf(tokens[i + 1], "%", tokens[i], line);
|
|
26446
|
+
if (minPercent === void 0) return;
|
|
26447
|
+
if (minPercent > 100) {
|
|
26448
|
+
this.error("Availability cannot be more than 100%", this.locOf(tokens[i + 1], line));
|
|
26449
|
+
return;
|
|
26450
|
+
}
|
|
26451
|
+
if (!this.expectEnd(tokens, i + 2, line)) return;
|
|
26452
|
+
commit({ kind: "availability", ...withUseCase(), minPercent, loc });
|
|
26453
|
+
return;
|
|
26454
|
+
}
|
|
26455
|
+
case "durable":
|
|
26456
|
+
readUseCase();
|
|
26457
|
+
if (!useCase) {
|
|
26458
|
+
this.error('Expected a use case name in quotes, e.g. durable "Shorten"', this.locOf(tokens[1] ?? first, line));
|
|
26459
|
+
return;
|
|
26460
|
+
}
|
|
26461
|
+
if (!this.expectEnd(tokens, i, line)) return;
|
|
26462
|
+
commit({ kind: "durable", useCase: useCase.name, loc });
|
|
26463
|
+
return;
|
|
26464
|
+
case "survive": {
|
|
26465
|
+
if (isWord(tokens[1], "any") && isWord(tokens[2], "node") && isWord(tokens[3], "failure")) {
|
|
26466
|
+
if (this.expectEnd(tokens, 4, line)) commit({ kind: "survive", target: "any", loc });
|
|
26467
|
+
return;
|
|
26468
|
+
}
|
|
26469
|
+
if (!isWord(tokens[1], "failure") || !isWord(tokens[2], "of")) {
|
|
26470
|
+
this.error("Expected survive any node failure, or survive failure of <node | [Tech] | any kind>", this.locOf(tokens[1] ?? first, line));
|
|
26471
|
+
return;
|
|
26472
|
+
}
|
|
26473
|
+
const target = this.parseSelector(tokens, 3, line);
|
|
26474
|
+
if (!target || !this.expectEnd(tokens, target.next, line)) return;
|
|
26475
|
+
commit({ kind: "survive", target: target.selector, loc });
|
|
26476
|
+
this.checkSelectorLater(target);
|
|
26477
|
+
return;
|
|
26478
|
+
}
|
|
26479
|
+
case "cost": {
|
|
26480
|
+
if (!this.expectOp(tokens, 1, ["<=", "<"], "cost <= 3000 usd/month", line)) return;
|
|
26481
|
+
const maxUsdPerMonth = this.quantityOf(tokens[2], "usd/month", tokens[1], line);
|
|
26482
|
+
if (maxUsdPerMonth === void 0 || !this.expectEnd(tokens, 3, line)) return;
|
|
26483
|
+
commit({ kind: "cost", maxUsdPerMonth, loc });
|
|
26484
|
+
return;
|
|
26485
|
+
}
|
|
26486
|
+
default:
|
|
26487
|
+
this.error(`Unknown requirement '${word}'. ${help}`, this.locOf(first, line));
|
|
26488
|
+
}
|
|
26489
|
+
}
|
|
26490
|
+
/** `db 20k rps latency 4ms availability 99.95% cost 400 usd/month durable`, parts in any order. */
|
|
26491
|
+
parseCapacityLine(tokens, line) {
|
|
26492
|
+
const [id] = tokens;
|
|
26493
|
+
if (id.kind !== "ident") {
|
|
26494
|
+
this.error("Expected a node id and its overrides, e.g. db 20k rps latency 4ms", this.locOf(id, line));
|
|
26495
|
+
return;
|
|
26496
|
+
}
|
|
26497
|
+
const override = { node: id.value, loc: this.lineLoc(tokens, line) };
|
|
26498
|
+
const seen = /* @__PURE__ */ new Set();
|
|
26499
|
+
for (let i = 1; i < tokens.length; ) {
|
|
26500
|
+
const t = tokens[i];
|
|
26501
|
+
let part;
|
|
26502
|
+
if (t.kind === "quantity" || t.kind === "number") {
|
|
26503
|
+
part = "rate";
|
|
26504
|
+
const rps = this.quantityOf(t, "rps", t, line);
|
|
26505
|
+
if (rps === void 0) return;
|
|
26506
|
+
override.rps = rps;
|
|
26507
|
+
i++;
|
|
26508
|
+
} else if (isWord(t, "latency") || isWord(t, "availability") || isWord(t, "cost")) {
|
|
26509
|
+
part = t.value;
|
|
26510
|
+
const unit = part === "latency" ? "ms" : part === "availability" ? "%" : "usd/month";
|
|
26511
|
+
const value = this.quantityOf(tokens[i + 1], unit, t, line);
|
|
26512
|
+
if (value === void 0) return;
|
|
26513
|
+
if (part === "latency") override.latencyMs = value;
|
|
26514
|
+
else if (part === "availability") override.availability = value;
|
|
26515
|
+
else override.costUsd = value;
|
|
26516
|
+
i += 2;
|
|
26517
|
+
} else if (isWord(t, "durable") || isWord(t, "volatile")) {
|
|
26518
|
+
part = "durable or volatile";
|
|
26519
|
+
override.durable = t.value === "durable";
|
|
26520
|
+
i++;
|
|
26521
|
+
} else if (isWord(t, "reads") || isWord(t, "writes")) {
|
|
26522
|
+
part = t.value;
|
|
26523
|
+
const value = this.quantityOf(tokens[i + 1], "rps", t, line);
|
|
26524
|
+
if (value === void 0) return;
|
|
26525
|
+
if (part === "reads") override.readRps = value;
|
|
26526
|
+
else override.writeRps = value;
|
|
26527
|
+
i += 2;
|
|
26528
|
+
} else if (isWord(t, "shards")) {
|
|
26529
|
+
part = "shards";
|
|
26530
|
+
const n = tokens[i + 1];
|
|
26531
|
+
if (n?.kind !== "number" || Number(n.value) < 1) {
|
|
26532
|
+
this.error("Expected a whole number of shards, at least 1, e.g. shards 4", this.locOf(n ?? t, line));
|
|
26533
|
+
return;
|
|
26534
|
+
}
|
|
26535
|
+
override.shards = Number(n.value);
|
|
26536
|
+
i += 2;
|
|
26537
|
+
} else if (isWord(t, "consistency")) {
|
|
26538
|
+
part = "consistency";
|
|
26539
|
+
const value = tokens[i + 1];
|
|
26540
|
+
if (!isWord(value, "strong") && !isWord(value, "eventual")) {
|
|
26541
|
+
this.error(`Expected strong or eventual after consistency${value ? `, not ${describe(value)}` : ""}`, this.locOf(value ?? t, line));
|
|
26542
|
+
return;
|
|
26543
|
+
}
|
|
26544
|
+
override.consistency = value.value;
|
|
26545
|
+
i += 2;
|
|
26546
|
+
} else if (isWord(t, "bandwidth") || isWord(t, "egress")) {
|
|
26547
|
+
part = t.value;
|
|
26548
|
+
const value = this.quantityOf(tokens[i + 1], part === "bandwidth" ? "MBps" : "usd/GB", t, line);
|
|
26549
|
+
if (value === void 0) return;
|
|
26550
|
+
if (part === "bandwidth") {
|
|
26551
|
+
if (value <= 0) {
|
|
26552
|
+
this.error("Bandwidth must be more than 0", this.locOf(tokens[i + 1], line));
|
|
26553
|
+
return;
|
|
26554
|
+
}
|
|
26555
|
+
override.bandwidthMBps = value;
|
|
26556
|
+
} else override.egressUsdPerGb = value;
|
|
26557
|
+
i += 2;
|
|
26558
|
+
} else {
|
|
26559
|
+
this.error(`Unexpected ${describe(t)}; expected ${CAPACITY_PARTS}`, this.locOf(t, line));
|
|
26560
|
+
return;
|
|
26561
|
+
}
|
|
26562
|
+
const clash = part === "rate" ? ["reads", "writes"].find((p) => seen.has(p)) : part === "reads" || part === "writes" ? seen.has("rate") ? part : void 0 : void 0;
|
|
26563
|
+
if (clash) {
|
|
26564
|
+
this.error(`A rate sets both reads and writes; give either a rate or reads and writes for '${id.value}'`, this.locOf(t, line));
|
|
26565
|
+
return;
|
|
26566
|
+
}
|
|
26567
|
+
if (seen.has(part)) {
|
|
26568
|
+
this.error(`${part[0].toUpperCase()}${part.slice(1)} is given twice for '${id.value}'`, this.locOf(t, line));
|
|
26569
|
+
return;
|
|
26570
|
+
}
|
|
26571
|
+
seen.add(part);
|
|
26572
|
+
}
|
|
26573
|
+
const idLoc = this.locOf(id, line);
|
|
26574
|
+
if (seen.size === 0) {
|
|
26575
|
+
this.warning(`Capacity line for '${id.value}' overrides nothing; add ${CAPACITY_PARTS}`, idLoc);
|
|
26576
|
+
return;
|
|
26577
|
+
}
|
|
26578
|
+
const first = this.capacity.find((c) => c.node === id.value);
|
|
26579
|
+
if (first) {
|
|
26580
|
+
this.error(`Duplicate capacity for '${id.value}' (first ${this.where(first.loc, idLoc)})`, idLoc);
|
|
26581
|
+
return;
|
|
26582
|
+
}
|
|
26583
|
+
this.capacity.push(override);
|
|
26584
|
+
this.deferred.push(() => {
|
|
26585
|
+
const node = this.nodes.get(id.value);
|
|
26586
|
+
if (!node) {
|
|
26587
|
+
this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
|
|
26588
|
+
return;
|
|
26589
|
+
}
|
|
26590
|
+
const storeOnly = ["shards", "consistency"].filter((p) => seen.has(p));
|
|
26591
|
+
if (storeOnly.length && !isDataStore(kindOf(node)))
|
|
26592
|
+
this.warning(`'${id.value}' is not a data store (${DATA_STORE_KINDS.join(", ")}); ${storeOnly.join(" and ")} only apply to data stores`, idLoc);
|
|
26593
|
+
});
|
|
26594
|
+
}
|
|
26595
|
+
/** `entity Url [in db] ["description"] {` */
|
|
26596
|
+
parseEntity(tokens, line) {
|
|
26597
|
+
const name = tokens[1];
|
|
26598
|
+
let i = 2;
|
|
26599
|
+
let store;
|
|
26600
|
+
if (isWord(tokens[i], "in")) {
|
|
26601
|
+
const id = tokens[i + 1];
|
|
26602
|
+
if (id?.kind !== "ident") {
|
|
26603
|
+
this.error("Expected the id of the node that stores the entity after in, e.g. entity Url in db {", this.locOf(id ?? tokens[i], line));
|
|
26604
|
+
this.skipSection("entity", tokens, line);
|
|
26605
|
+
return;
|
|
26606
|
+
}
|
|
26607
|
+
store = { id: id.value, loc: this.locOf(id, line) };
|
|
26608
|
+
i += 2;
|
|
26609
|
+
}
|
|
26610
|
+
const description = tokens[i]?.kind === "string" ? tokens[i++].value : void 0;
|
|
26611
|
+
if (tokens[i]?.kind !== "lbrace") {
|
|
26612
|
+
this.error('Expected { after the entity header, e.g. entity Url in db "Short codes" {', this.locOf(tokens[i] ?? tokens[i - 1], line));
|
|
26613
|
+
this.skipSection("entity", tokens, line);
|
|
26614
|
+
return;
|
|
26615
|
+
}
|
|
26616
|
+
this.expectEnd(tokens, i + 1, line);
|
|
26617
|
+
const loc = this.locOf(name, line);
|
|
26618
|
+
const entity = { name: name.value, ...store ? { store: store.id } : {}, ...description !== void 0 ? { description } : {}, fields: [], loc };
|
|
26619
|
+
const first = this.entities.find((e) => e.name === entity.name);
|
|
26620
|
+
if (first) {
|
|
26621
|
+
this.error(`Duplicate entity '${entity.name}' (first ${this.where(first.loc, loc)})`, loc);
|
|
26622
|
+
this.skipSection("entity", tokens, line);
|
|
26623
|
+
return;
|
|
26624
|
+
}
|
|
26625
|
+
if (!this.openSection("entity", tokens, line, { entity })) return;
|
|
26626
|
+
this.entities.push(entity);
|
|
26627
|
+
if (store) {
|
|
26628
|
+
const { id, loc: storeLoc } = store;
|
|
26629
|
+
this.deferred.push(() => {
|
|
26630
|
+
const node = this.nodes.get(id);
|
|
26631
|
+
if (!node) this.warning(`Unknown node '${id}'`, storeLoc);
|
|
26632
|
+
else if (!isDataStore(kindOf(node)))
|
|
26633
|
+
this.warning(`'${id}' is not a data store (${DATA_STORE_KINDS.join(", ")}); entity '${entity.name}' is placed in a ${kindOf(node)}`, storeLoc);
|
|
26634
|
+
});
|
|
26635
|
+
}
|
|
26636
|
+
}
|
|
26637
|
+
/** `code string key`: a field name, a type and flags. */
|
|
26638
|
+
parseField(entity, tokens, line) {
|
|
26639
|
+
const [name, type] = tokens;
|
|
26640
|
+
if (name.kind !== "ident") {
|
|
26641
|
+
this.error("Expected a field: name type [key] [index] [unique] [optional], e.g. code string key", this.locOf(name, line));
|
|
26642
|
+
return;
|
|
26643
|
+
}
|
|
26644
|
+
if (type?.kind !== "ident") {
|
|
26645
|
+
this.error(`Expected a type after '${name.value}', e.g. ${name.value} string`, this.locOf(type ?? name, line));
|
|
26646
|
+
return;
|
|
26647
|
+
}
|
|
26648
|
+
const flags = [];
|
|
26649
|
+
for (const t of tokens.slice(2)) {
|
|
26650
|
+
if (t.kind !== "ident" || !FIELD_FLAGS.includes(t.value)) {
|
|
26651
|
+
this.error(`Unknown field flag ${describe(t)}; use key, index, unique or optional`, this.locOf(t, line));
|
|
26652
|
+
return;
|
|
26653
|
+
}
|
|
26654
|
+
if (flags.includes(t.value)) {
|
|
26655
|
+
this.error(`Flag '${t.value}' is given twice`, this.locOf(t, line));
|
|
26656
|
+
return;
|
|
26657
|
+
}
|
|
26658
|
+
flags.push(t.value);
|
|
26659
|
+
}
|
|
26660
|
+
if (entity.fields.some((f) => f.name === name.value)) {
|
|
26661
|
+
this.error(`Duplicate field '${name.value}' in entity '${entity.name}'`, this.locOf(name, line));
|
|
26662
|
+
return;
|
|
26663
|
+
}
|
|
26664
|
+
entity.fields.push({ name: name.value, type: type.value, flags });
|
|
26665
|
+
}
|
|
26666
|
+
/** `decision "Title" because "Reason"`, or `decision "Title" {` with because / rejected lines. */
|
|
26667
|
+
parseDecision(tokens, line) {
|
|
26668
|
+
const title = tokens[1];
|
|
26669
|
+
const decision = { title: title.value, rejected: [], loc: this.locOf(title, line) };
|
|
26670
|
+
const third = tokens[2];
|
|
26671
|
+
if (third.kind === "lbrace") {
|
|
26672
|
+
this.expectEnd(tokens, 3, line);
|
|
26673
|
+
if (this.openSection("decision", tokens, line, { decision })) this.decisions.push(decision);
|
|
26674
|
+
return;
|
|
26675
|
+
}
|
|
26676
|
+
if (!isWord(third, "because")) {
|
|
26677
|
+
this.error(`Expected because "reason" or { after the decision title, not ${describe(third)}`, this.locOf(third, line));
|
|
26678
|
+
this.skipSection("decision", tokens, line);
|
|
26679
|
+
return;
|
|
26680
|
+
}
|
|
26681
|
+
if (this.stack.length > 0) {
|
|
26682
|
+
this.error("decision is only allowed at the top level", this.locOf(tokens[0], line));
|
|
26683
|
+
return;
|
|
26684
|
+
}
|
|
26685
|
+
const reason = tokens[3];
|
|
26686
|
+
if (reason?.kind !== "string") {
|
|
26687
|
+
this.error("Expected the reason in quotes after because", this.locOf(reason ?? third, line));
|
|
26688
|
+
return;
|
|
26689
|
+
}
|
|
26690
|
+
if (!this.expectEnd(tokens, 4, line)) return;
|
|
26691
|
+
decision.because = reason.value;
|
|
26692
|
+
this.decisions.push(decision);
|
|
26693
|
+
}
|
|
26694
|
+
parseDecisionLine(decision, tokens, line) {
|
|
26695
|
+
const [word] = tokens;
|
|
26696
|
+
if (isWord(word, "because")) {
|
|
26697
|
+
if (tokens[1]?.kind !== "string") {
|
|
26698
|
+
this.error("Expected the reason in quotes after because", this.locOf(tokens[1] ?? word, line));
|
|
26699
|
+
return;
|
|
26700
|
+
}
|
|
26701
|
+
if (decision.because !== void 0) {
|
|
26702
|
+
this.error('A decision has one because; list the other options with rejected "option" "reason"', this.locOf(word, line));
|
|
26703
|
+
return;
|
|
26704
|
+
}
|
|
26705
|
+
if (this.expectEnd(tokens, 2, line)) decision.because = tokens[1].value;
|
|
26706
|
+
return;
|
|
26707
|
+
}
|
|
26708
|
+
if (isWord(word, "rejected")) {
|
|
26709
|
+
const [, option, reason] = tokens;
|
|
26710
|
+
if (option?.kind !== "string" || reason?.kind !== "string") {
|
|
26711
|
+
const bad = option?.kind !== "string" ? option : reason;
|
|
26712
|
+
this.error('Expected rejected "option" "reason"', this.locOf(bad ?? tokens[tokens.length - 1], line));
|
|
26713
|
+
return;
|
|
26714
|
+
}
|
|
26715
|
+
if (this.expectEnd(tokens, 3, line)) decision.rejected.push({ option: option.value, reason: reason.value });
|
|
26716
|
+
return;
|
|
26717
|
+
}
|
|
26718
|
+
this.error('Expected because "reason" or rejected "option" "reason"', this.locOf(word, line));
|
|
26719
|
+
}
|
|
26720
|
+
/** `test "Name" {` */
|
|
26721
|
+
parseTest(tokens, line) {
|
|
26722
|
+
const name = tokens[1];
|
|
26723
|
+
const loc = this.locOf(name, line);
|
|
26724
|
+
if (tokens[2].kind !== "lbrace") {
|
|
26725
|
+
this.error(`Expected { after the test name, not ${describe(tokens[2])}`, this.locOf(tokens[2], line));
|
|
26726
|
+
this.skipSection("test", tokens, line);
|
|
26727
|
+
return;
|
|
26728
|
+
}
|
|
26729
|
+
this.expectEnd(tokens, 3, line);
|
|
26730
|
+
const first = this.tests.find((t) => t.name === name.value);
|
|
26731
|
+
if (first) {
|
|
26732
|
+
this.error(`Duplicate test '${name.value}' (first ${this.where(first.loc, loc)})`, loc);
|
|
26733
|
+
this.skipSection("test", tokens, line);
|
|
26734
|
+
return;
|
|
26735
|
+
}
|
|
26736
|
+
const test = { name: name.value, assertions: [], loc };
|
|
26737
|
+
if (this.openSection("test", tokens, line, { test })) this.tests.push(test);
|
|
26738
|
+
}
|
|
26739
|
+
/** One assertion line of a test (docs/design/hld-and-practice.md §1.9). */
|
|
26740
|
+
parseAssertion(tokens, line) {
|
|
26741
|
+
const loc = this.lineLoc(tokens, line);
|
|
26742
|
+
const [first] = tokens;
|
|
26743
|
+
const selectors = [];
|
|
26744
|
+
const selector = (i2) => {
|
|
26745
|
+
const s = this.parseSelector(tokens, i2, line);
|
|
26746
|
+
if (s) selectors.push(s);
|
|
26747
|
+
return s;
|
|
26748
|
+
};
|
|
26749
|
+
const done = (assertion3, end) => {
|
|
26750
|
+
if (!this.expectEnd(tokens, end, line)) return void 0;
|
|
26751
|
+
selectors.forEach((s) => this.checkSelectorLater(s));
|
|
26752
|
+
return assertion3;
|
|
26753
|
+
};
|
|
26754
|
+
const expectWords = (i2, words, example) => {
|
|
26755
|
+
for (const [k, w] of words.entries()) {
|
|
26756
|
+
if (!isWord(tokens[i2 + k], w)) {
|
|
26757
|
+
this.error(`Expected '${w}' here, e.g. ${example}`, this.locOf(tokens[i2 + k] ?? tokens[tokens.length - 1], line));
|
|
26758
|
+
return false;
|
|
26759
|
+
}
|
|
26760
|
+
}
|
|
26761
|
+
return true;
|
|
26762
|
+
};
|
|
26763
|
+
if (isWord(first, "no") && isWord(tokens[1], "path")) {
|
|
26764
|
+
const example = "no path from client to any database";
|
|
26765
|
+
if (!expectWords(2, ["from"], example)) return;
|
|
26766
|
+
const from = selector(3);
|
|
26767
|
+
if (!from || !expectWords(from.next, ["to"], example)) return;
|
|
26768
|
+
const to = selector(from.next + 1);
|
|
26769
|
+
if (!to) return;
|
|
26770
|
+
return done({ kind: "noPath", from: from.selector, to: to.selector, loc }, to.next);
|
|
26771
|
+
}
|
|
26772
|
+
if (first.kind !== "string") {
|
|
26773
|
+
if (first.kind !== "ident" && first.kind !== "tech") {
|
|
26774
|
+
this.error(ASSERTION_HELP, this.locOf(first, line));
|
|
26775
|
+
return;
|
|
26776
|
+
}
|
|
26777
|
+
const inUseCase = isWord(first, "in") && tokens[1]?.kind === "string" ? { name: tokens[1].value, loc: this.locOf(tokens[1], line) } : void 0;
|
|
26778
|
+
const target = selector(inUseCase ? 2 : 0);
|
|
26779
|
+
if (!target) return;
|
|
26780
|
+
const word = tokens[target.next];
|
|
26781
|
+
if (isWord(word, "calls") || isWord(word, "never")) {
|
|
26782
|
+
const never = word.value === "never";
|
|
26783
|
+
if (never && !expectWords(target.next + 1, ["calls"], "any service never calls any storage")) return;
|
|
26784
|
+
const to = selector(target.next + (never ? 2 : 1));
|
|
26785
|
+
if (!to) return;
|
|
26786
|
+
const assertion3 = done(
|
|
26787
|
+
{ kind: "senderCalls", ...inUseCase ? { useCase: inUseCase.name } : {}, from: target.selector, to: to.selector, quantifier: never ? "never" : "some", loc },
|
|
26788
|
+
to.next
|
|
26789
|
+
);
|
|
26790
|
+
if (assertion3 && inUseCase) this.deferred.push(() => this.checkUseCase(inUseCase.name, inUseCase.loc));
|
|
26791
|
+
return assertion3;
|
|
26792
|
+
}
|
|
26793
|
+
if (inUseCase) {
|
|
26794
|
+
this.error(`Expected calls or never calls here, e.g. in "${inUseCase.name}" api calls db`, this.locOf(word ?? tokens[tokens.length - 1], line));
|
|
26795
|
+
return;
|
|
26796
|
+
}
|
|
26797
|
+
if (!isWord(word, "has")) {
|
|
26798
|
+
this.error(ASSERTION_HELP, this.locOf(word ?? first, line));
|
|
26799
|
+
return;
|
|
26800
|
+
}
|
|
26801
|
+
const i2 = target.next + 1;
|
|
26802
|
+
const example = "api has replicas >= 2";
|
|
26803
|
+
if (!expectWords(i2, ["replicas"], example) || !this.expectOp(tokens, i2 + 1, [">="], example, line)) return;
|
|
26804
|
+
const n = tokens[i2 + 2];
|
|
26805
|
+
if (n?.kind !== "number" || Number(n.value) < 1) {
|
|
26806
|
+
this.error("Expected a whole number of replicas, at least 1", this.locOf(n ?? tokens[i2 + 1], line));
|
|
26807
|
+
return;
|
|
26808
|
+
}
|
|
26809
|
+
return done({ kind: "replicas", target: target.selector, min: Number(n.value), loc }, i2 + 3);
|
|
26810
|
+
}
|
|
26811
|
+
const useCase = first.value;
|
|
26812
|
+
const useCaseLoc = this.locOf(first, line);
|
|
26813
|
+
let i = 1;
|
|
26814
|
+
let scenario;
|
|
26815
|
+
if (isWord(tokens[i], "scenario")) {
|
|
26816
|
+
const s = tokens[i + 1];
|
|
26817
|
+
if (s?.kind !== "string") {
|
|
26818
|
+
this.error("Expected a scenario name in quotes after scenario", this.locOf(s ?? tokens[i], line));
|
|
26819
|
+
return;
|
|
26820
|
+
}
|
|
26821
|
+
scenario = { name: s.value, loc: this.locOf(s, line) };
|
|
26822
|
+
i += 2;
|
|
26823
|
+
}
|
|
26824
|
+
const withScenario = scenario ? { scenario: scenario.name } : {};
|
|
26825
|
+
const verb = tokens[i];
|
|
26826
|
+
const wholeUseCase = (what) => {
|
|
26827
|
+
if (!scenario) return true;
|
|
26828
|
+
this.error(`${what} is about the whole use case; leave out scenario "\u2026"`, scenario.loc);
|
|
26829
|
+
return false;
|
|
26830
|
+
};
|
|
26831
|
+
let assertion2;
|
|
26832
|
+
if (isWord(verb, "calls")) {
|
|
26833
|
+
const target = selector(i + 1);
|
|
26834
|
+
if (!target) return;
|
|
26835
|
+
if (isWord(tokens[target.next], "before")) {
|
|
26836
|
+
const then = selector(target.next + 1);
|
|
26837
|
+
if (!then) return;
|
|
26838
|
+
assertion2 = done({ kind: "before", useCase, ...withScenario, first: target.selector, then: then.selector, loc }, then.next);
|
|
26839
|
+
} else if (isWord(tokens[target.next], "after")) {
|
|
26840
|
+
const after2 = selector(target.next + 1);
|
|
26841
|
+
if (!after2) return;
|
|
26842
|
+
assertion2 = done({ kind: "after", useCase, ...withScenario, target: target.selector, after: after2.selector, loc }, after2.next);
|
|
26843
|
+
} else {
|
|
26844
|
+
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
|
|
26845
|
+
}
|
|
26846
|
+
} else if (isWord(verb, "every")) {
|
|
26847
|
+
if (!expectWords(i + 1, ["scenario", "calls"], `"${useCase}" every scenario calls any cache`)) return;
|
|
26848
|
+
const target = selector(i + 3);
|
|
26849
|
+
if (!target) return;
|
|
26850
|
+
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "every", loc }, target.next);
|
|
26851
|
+
} else if (isWord(verb, "never") && isWord(tokens[i + 1], "waits")) {
|
|
26852
|
+
if (!expectWords(i + 2, ["for"], `"${useCase}" never waits for any queue`)) return;
|
|
26853
|
+
const target = selector(i + 3);
|
|
26854
|
+
if (!target) return;
|
|
26855
|
+
assertion2 = done({ kind: "neverWaits", useCase, ...withScenario, target: target.selector, loc }, target.next);
|
|
26856
|
+
} else if (isWord(verb, "starts")) {
|
|
26857
|
+
if (!wholeUseCase("starts at") || !expectWords(i + 1, ["at"], `"${useCase}" starts at any queue`)) return;
|
|
26858
|
+
const target = selector(i + 2);
|
|
26859
|
+
if (!target) return;
|
|
26860
|
+
assertion2 = done({ kind: "startsAt", useCase, target: target.selector, loc }, target.next);
|
|
26861
|
+
} else if (isWord(verb, "never")) {
|
|
26862
|
+
if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
|
|
26863
|
+
const target = selector(i + 2);
|
|
26864
|
+
if (!target) return;
|
|
26865
|
+
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "never", loc }, target.next);
|
|
26866
|
+
} else if (isWord(verb, "writes")) {
|
|
26867
|
+
const target = selector(i + 1);
|
|
26868
|
+
if (!target || !expectWords(target.next, ["before", "responding"], `"${useCase}" writes db before responding`)) return;
|
|
26869
|
+
assertion2 = done({ kind: "writesBeforeResponding", useCase, ...withScenario, target: target.selector, loc }, target.next + 2);
|
|
26870
|
+
} else if (isWord(verb, "responds")) {
|
|
26871
|
+
const status = tokens[i + 1];
|
|
26872
|
+
if (!status || status.kind !== "number" && status.kind !== "quantity" || !/^[1-5](\d\d|xx)$/.test(status.value)) {
|
|
26873
|
+
this.error("Expected a status code or class after responds, e.g. 201 or 4xx", this.locOf(status ?? verb, line));
|
|
26874
|
+
return;
|
|
26875
|
+
}
|
|
26876
|
+
assertion2 = done({ kind: "responds", useCase, ...withScenario, status: status.value, loc }, i + 2);
|
|
26877
|
+
} else if (isWord(verb, "has")) {
|
|
26878
|
+
if (!wholeUseCase("has scenario") || !expectWords(i + 1, ["scenario"], `"${useCase}" has scenario "Not found"`)) return;
|
|
26879
|
+
const name = tokens[i + 2];
|
|
26880
|
+
if (name?.kind !== "string") {
|
|
26881
|
+
this.error("Expected a scenario name in quotes after has scenario", this.locOf(name ?? tokens[i + 1], line));
|
|
26882
|
+
return;
|
|
26883
|
+
}
|
|
26884
|
+
assertion2 = done({ kind: "hasScenario", useCase, scenario: name.value, loc }, i + 3);
|
|
26885
|
+
} else if (isWord(verb, "handles")) {
|
|
26886
|
+
if (!wholeUseCase("handles failure") || !expectWords(i + 1, ["failure", "of"], `"${useCase}" handles failure of cache`)) return;
|
|
26887
|
+
const target = selector(i + 3);
|
|
26888
|
+
if (!target) return;
|
|
26889
|
+
assertion2 = done({ kind: "handlesFailure", useCase, target: target.selector, loc }, target.next);
|
|
26890
|
+
} else {
|
|
26891
|
+
this.error(`Expected ${ASSERTION_VERBS} after "${useCase}"`, this.locOf(verb ?? first, line));
|
|
26892
|
+
return;
|
|
26893
|
+
}
|
|
26894
|
+
if (assertion2) {
|
|
26895
|
+
this.deferred.push(() => {
|
|
26896
|
+
const found = this.checkUseCase(useCase, useCaseLoc);
|
|
26897
|
+
if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
|
|
26898
|
+
});
|
|
26899
|
+
}
|
|
26900
|
+
return assertion2;
|
|
26901
|
+
}
|
|
26902
|
+
/** A selector starting at `tokens[i]`: one or more of node id, `[Tech]`, `any <kind>`, `any strong store`, joined by `or`. */
|
|
26903
|
+
parseSelector(tokens, i, line) {
|
|
26904
|
+
const atoms = [];
|
|
26905
|
+
let next = i;
|
|
26906
|
+
for (; ; ) {
|
|
26907
|
+
const atom = this.parseSelectorAtom(tokens, next, line);
|
|
26908
|
+
if (!atom) return void 0;
|
|
26909
|
+
atoms.push(atom);
|
|
26910
|
+
next = atom.next;
|
|
26911
|
+
if (!isWord(tokens[next], "or")) break;
|
|
26912
|
+
next++;
|
|
26913
|
+
}
|
|
26914
|
+
const selector = atoms.length === 1 ? atoms[0].selector : { anyOf: atoms.map((a) => a.selector) };
|
|
26915
|
+
return { selector, next, atoms };
|
|
26916
|
+
}
|
|
26917
|
+
/** One node id, `[Tech]`, `any <kind>` or `any strong|eventual store` at `tokens[i]`. */
|
|
26918
|
+
parseSelectorAtom(tokens, i, line) {
|
|
26919
|
+
const t = tokens[i];
|
|
26920
|
+
if (t?.kind === "tech") {
|
|
26921
|
+
const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
|
|
26922
|
+
return { selector: { tech }, next: i + 1, token: t, loc: this.locOf(t, line) };
|
|
26923
|
+
}
|
|
26924
|
+
if (isWord(t, "any")) {
|
|
26925
|
+
const kind = tokens[i + 1];
|
|
26926
|
+
if (isWord(kind, "strong") || isWord(kind, "eventual")) {
|
|
26927
|
+
if (!isWord(tokens[i + 2], "store")) {
|
|
26928
|
+
this.error(`Expected 'store' here, e.g. any ${kind.value} store`, this.locOf(tokens[i + 2] ?? kind, line));
|
|
26929
|
+
return void 0;
|
|
26930
|
+
}
|
|
26931
|
+
return { selector: { consistency: kind.value }, next: i + 3, token: t, loc: this.locOf(t, line) };
|
|
26932
|
+
}
|
|
26933
|
+
if (kind?.kind !== "ident" || !isKind(kind.value)) {
|
|
26934
|
+
this.error(`Expected a kind after any: ${KINDS.join(", ")}, or strong store / eventual store`, this.locOf(kind ?? t, line));
|
|
26935
|
+
return void 0;
|
|
26936
|
+
}
|
|
26937
|
+
return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
|
|
26938
|
+
}
|
|
26939
|
+
if (t?.kind === "ident") return { selector: { node: t.value }, next: i + 1, token: t, loc: this.locOf(t, line) };
|
|
26940
|
+
this.error(`Expected a node id, [Tech] or any <kind>${t ? `, not ${describe(t)}` : ""}`, this.locOf(t ?? tokens[tokens.length - 1], line));
|
|
26941
|
+
return void 0;
|
|
26942
|
+
}
|
|
26943
|
+
/** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
|
|
26944
|
+
checkSelectorLater({ atoms }) {
|
|
26945
|
+
for (const { selector, token, loc } of atoms) {
|
|
26946
|
+
if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
|
|
26947
|
+
if ("node" in selector) {
|
|
26948
|
+
this.deferred.push(() => {
|
|
26949
|
+
if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
|
|
26950
|
+
});
|
|
26951
|
+
}
|
|
26952
|
+
}
|
|
26953
|
+
}
|
|
26954
|
+
checkUseCase(name, loc) {
|
|
26955
|
+
const useCase = this.built.find((u) => u.name === name);
|
|
26956
|
+
if (!useCase) this.warning(`Unknown use case '${name}'`, loc);
|
|
26957
|
+
return useCase;
|
|
26958
|
+
}
|
|
26959
|
+
checkScenario(useCase, name, loc) {
|
|
26960
|
+
if (useCase.scenarios.some((s) => s.name === name)) return;
|
|
26961
|
+
const names = useCase.scenarios.map((s) => `'${s.name}'`).join(", ");
|
|
26962
|
+
this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
|
|
26963
|
+
}
|
|
26964
|
+
/** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
|
|
26965
|
+
quantityOf(token, unit, after2, line) {
|
|
26966
|
+
const expected = UNIT_EXAMPLES[unit];
|
|
26967
|
+
if (token?.kind !== "quantity" && token?.kind !== "number") {
|
|
26968
|
+
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after2, line));
|
|
26969
|
+
return void 0;
|
|
26970
|
+
}
|
|
26971
|
+
const loc = this.locOf(token, line);
|
|
26972
|
+
const written = this.lines[line - 1].slice(token.col - 1, token.col - 1 + token.length);
|
|
26973
|
+
const q = parseQuantity(written);
|
|
26974
|
+
if ("error" in q) {
|
|
26975
|
+
this.error(q.error, loc);
|
|
26976
|
+
return void 0;
|
|
26977
|
+
}
|
|
26978
|
+
if (q.unit !== unit) {
|
|
26979
|
+
this.error(q.unit ? `Expected ${expected}, not '${written}'` : `'${written}' needs a unit: expected ${expected}`, loc);
|
|
26980
|
+
return void 0;
|
|
26981
|
+
}
|
|
26982
|
+
return q.value;
|
|
26983
|
+
}
|
|
26984
|
+
expectOp(tokens, i, ops, example, line) {
|
|
26985
|
+
const t = tokens[i];
|
|
26986
|
+
if (t?.kind === "op" && ops.includes(t.value)) return true;
|
|
26987
|
+
this.error(`Expected ${ops[0]} here, e.g. ${example}`, this.locOf(t ?? tokens[tokens.length - 1], line));
|
|
26988
|
+
return false;
|
|
26989
|
+
}
|
|
25982
26990
|
buildUseCase(useCase, body) {
|
|
25983
26991
|
let paths = expand(body.items);
|
|
25984
26992
|
if (paths.length > MAX_SCENARIOS) {
|
|
@@ -26021,22 +27029,22 @@ var Parser = class {
|
|
|
26021
27029
|
for (const step of raw) {
|
|
26022
27030
|
this.checkConnection(step);
|
|
26023
27031
|
if (step.arrow === "-->") {
|
|
26024
|
-
const
|
|
26025
|
-
if (!
|
|
27032
|
+
const request2 = [...steps].reverse().find((s) => s.fromServiceId === step.to && s.toServiceId === step.from && !answered.has(s) && !s.failed);
|
|
27033
|
+
if (!request2) {
|
|
26026
27034
|
this.warning(`Response has no matching request from '${step.to}' to '${step.from}'`, step.loc);
|
|
26027
27035
|
continue;
|
|
26028
27036
|
}
|
|
26029
|
-
answered.add(
|
|
26030
|
-
|
|
27037
|
+
answered.add(request2);
|
|
27038
|
+
request2.responseLoc = step.loc;
|
|
26031
27039
|
const status = step.label.match(/^(\d{3})\b\s*([\s\S]*)$/);
|
|
26032
|
-
if (status)
|
|
27040
|
+
if (status) request2.statusCode = Number(status[1]);
|
|
26033
27041
|
const payload = splitPayload(status ? status[2] : step.label);
|
|
26034
27042
|
const body = payload.body ?? payload.text;
|
|
26035
27043
|
if (body) {
|
|
26036
|
-
|
|
26037
|
-
|
|
27044
|
+
request2.responseBody = body;
|
|
27045
|
+
request2.responseFormat = payload.body ? payload.format : "FREE_TEXT";
|
|
26038
27046
|
}
|
|
26039
|
-
if (
|
|
27047
|
+
if (request2.executionType === "ASYNC_FIRE_AND_FORGET") request2.executionType = "ASYNC_REQUEST_RESPONSE";
|
|
26040
27048
|
continue;
|
|
26041
27049
|
}
|
|
26042
27050
|
const label = parseStepLabel(step.label);
|
|
@@ -26060,7 +27068,10 @@ var Parser = class {
|
|
|
26060
27068
|
isParallel: step.parallelGroup !== void 0,
|
|
26061
27069
|
isConditional: false,
|
|
26062
27070
|
...step.arrow === "-x" ? { failed: true } : {},
|
|
26063
|
-
loc: step.loc
|
|
27071
|
+
loc: step.loc,
|
|
27072
|
+
...step.multiplier !== void 0 ? { multiplier: step.multiplier } : {},
|
|
27073
|
+
...step.sizeBytes !== void 0 ? { sizeBytes: step.sizeBytes } : {},
|
|
27074
|
+
access: accessOf(label.method, step.label)
|
|
26064
27075
|
});
|
|
26065
27076
|
}
|
|
26066
27077
|
return steps;
|
|
@@ -26083,13 +27094,15 @@ var Parser = class {
|
|
|
26083
27094
|
addNode(node) {
|
|
26084
27095
|
const existing = this.nodes.get(node.id);
|
|
26085
27096
|
if (existing) {
|
|
26086
|
-
|
|
26087
|
-
const where = first === node.loc.file ? `on line ${existing.loc.line}` : `in ${fileName(first ?? this.options.path ?? "the root document")} on line ${existing.loc.line}`;
|
|
26088
|
-
this.error(`Duplicate id '${node.id}' (first declared ${where})`, node.loc);
|
|
27097
|
+
this.error(`Duplicate id '${node.id}' (first declared ${this.where(existing.loc, node.loc)})`, node.loc);
|
|
26089
27098
|
return;
|
|
26090
27099
|
}
|
|
26091
27100
|
this.nodes.set(node.id, node);
|
|
26092
27101
|
}
|
|
27102
|
+
/** `on line 3`, or `in infra.proschi on line 3` when `first` is in another file than `later`. */
|
|
27103
|
+
where(first, later) {
|
|
27104
|
+
return first.file === later.file ? `on line ${first.line}` : `in ${fileName(first.file ?? this.options.path ?? "the root document")} on line ${first.line}`;
|
|
27105
|
+
}
|
|
26093
27106
|
resolveTech(token, line) {
|
|
26094
27107
|
const resolved = techByName.get(token.value.toLowerCase());
|
|
26095
27108
|
if (resolved) return resolved;
|
|
@@ -26127,8 +27140,17 @@ var Parser = class {
|
|
|
26127
27140
|
inUseCase() {
|
|
26128
27141
|
return this.currentUseCase() !== void 0;
|
|
26129
27142
|
}
|
|
27143
|
+
/** Reports the first token from `from` on; true when there is none. */
|
|
26130
27144
|
expectEnd(tokens, from, line) {
|
|
26131
|
-
if (tokens.length
|
|
27145
|
+
if (tokens.length <= from) return true;
|
|
27146
|
+
this.error(`Unexpected ${describe(tokens[from])}`, this.locOf(tokens[from], line));
|
|
27147
|
+
return false;
|
|
27148
|
+
}
|
|
27149
|
+
/** From the first token of a line to the end of its last one. */
|
|
27150
|
+
lineLoc(tokens, line) {
|
|
27151
|
+
const first = tokens[0];
|
|
27152
|
+
const last = tokens[tokens.length - 1];
|
|
27153
|
+
return { line, col: first.col, length: last.col + last.length - first.col, ...this.fileField() };
|
|
26132
27154
|
}
|
|
26133
27155
|
locOf(token, line) {
|
|
26134
27156
|
return token ? { line, col: token.col, length: token.length, ...this.fileField() } : { line, col: 1, length: 1, ...this.fileField() };
|
|
@@ -26170,6 +27192,15 @@ function expand(items) {
|
|
|
26170
27192
|
}
|
|
26171
27193
|
return paths;
|
|
26172
27194
|
}
|
|
27195
|
+
function isWord(token, word) {
|
|
27196
|
+
return token?.kind === "ident" && token.value === word;
|
|
27197
|
+
}
|
|
27198
|
+
function sectionName(frame) {
|
|
27199
|
+
if (frame.entity) return `entity '${frame.entity.name}'`;
|
|
27200
|
+
if (frame.decision) return `decision '${frame.decision.title}'`;
|
|
27201
|
+
if (frame.test) return `test '${frame.test.name}'`;
|
|
27202
|
+
return `${frame.section} block`;
|
|
27203
|
+
}
|
|
26173
27204
|
function fileName(path) {
|
|
26174
27205
|
return path.split(/[\\/]/).pop() || path;
|
|
26175
27206
|
}
|
|
@@ -26206,6 +27237,16 @@ function parseStepLabel(input) {
|
|
|
26206
27237
|
const payload = splitPayload(input);
|
|
26207
27238
|
return { name: payload.text, format: payload.body ? payload.format : void 0, body: payload.body };
|
|
26208
27239
|
}
|
|
27240
|
+
var SIZE_UNITS = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12 };
|
|
27241
|
+
function parseSize(text) {
|
|
27242
|
+
const m = /^(\d+(?:\.\d+)?)([A-Za-z]*)$/.exec(text);
|
|
27243
|
+
if (!m) return { error: `Expected a payload size after ~, e.g. ~2MB, ~500KB or ~1.5GB${text ? `, not '~${text}'` : ""}` };
|
|
27244
|
+
const factor = SIZE_UNITS[m[2]];
|
|
27245
|
+
if (factor === void 0) return { error: m[2] ? `Unknown size unit '${m[2]}' in '~${text}'; use B, KB, MB, GB or TB` : `'~${text}' needs a unit: B, KB, MB, GB or TB` };
|
|
27246
|
+
const value = Math.round(Number(m[1]) * factor);
|
|
27247
|
+
if (value <= 0) return { error: `A payload size must be more than 0, not '~${text}'` };
|
|
27248
|
+
return { value };
|
|
27249
|
+
}
|
|
26209
27250
|
function inferProtocol(isAsync, method, target) {
|
|
26210
27251
|
if (isAsync || target?.type === "queue") return "MESSAGING";
|
|
26211
27252
|
if (!method) return "OTHER";
|
|
@@ -26279,8 +27320,9 @@ function format(source2) {
|
|
|
26279
27320
|
};
|
|
26280
27321
|
tidyBlankLines(entries).forEach((entry) => {
|
|
26281
27322
|
const last = run[run.length - 1];
|
|
26282
|
-
if (last && (entry.kind !== last.kind || entry.depth !== last.depth || last.kind === "connection" && last.continuation.length))
|
|
26283
|
-
|
|
27323
|
+
if (last && (entry.kind !== last.kind || entry.depth !== last.depth || last.kind === "connection" && last.continuation.length || last.kind === "cells" && entry.kind === "cells" && entry.section !== last.section))
|
|
27324
|
+
flush();
|
|
27325
|
+
if (entry.kind === "node" || entry.kind === "connection" || entry.kind === "cells") run.push(entry);
|
|
26284
27326
|
else {
|
|
26285
27327
|
flush();
|
|
26286
27328
|
out.push(entry.kind === "blank" ? "" : indent(entry.depth) + entry.text);
|
|
@@ -26294,6 +27336,7 @@ var KEYWORDS = /* @__PURE__ */ new Set(["title", "import", "group", "usecase", "
|
|
|
26294
27336
|
function classify(lines) {
|
|
26295
27337
|
const entries = [];
|
|
26296
27338
|
let depth = 0;
|
|
27339
|
+
let section;
|
|
26297
27340
|
for (let i = 0; i < lines.length; i++) {
|
|
26298
27341
|
const raw = lines[i];
|
|
26299
27342
|
const original = raw.trim();
|
|
@@ -26309,7 +27352,13 @@ function classify(lines) {
|
|
|
26309
27352
|
const comment = commentOf(raw, tokens);
|
|
26310
27353
|
const base = { depth, original, tokens, comment };
|
|
26311
27354
|
const [first, second, third, fourth] = tokens;
|
|
26312
|
-
|
|
27355
|
+
const inSection = section !== void 0 && depth === 1 && first.kind !== "rbrace";
|
|
27356
|
+
const cells = inSection ? sectionCells(section, raw, tokens) : void 0;
|
|
27357
|
+
if (cells) {
|
|
27358
|
+
entries.push({ ...base, kind: "cells", section, cells });
|
|
27359
|
+
continue;
|
|
27360
|
+
}
|
|
27361
|
+
if (!inSection && first.kind === "ident" && second?.kind === "arrow" && third?.kind === "ident" && tokens.length <= 4 && (!fourth || fourth.kind === "label")) {
|
|
26313
27362
|
const entry = { ...base, kind: "connection", from: first.value, arrow: second.value, to: third.value, label: fourth?.value, continuation: [] };
|
|
26314
27363
|
if (fourth && bracketDepth(fourth.value) > 0) {
|
|
26315
27364
|
const delta = depth * INDENT.length - width(leadingSpace(raw));
|
|
@@ -26324,11 +27373,12 @@ function classify(lines) {
|
|
|
26324
27373
|
entries.push(entry);
|
|
26325
27374
|
continue;
|
|
26326
27375
|
}
|
|
26327
|
-
|
|
27376
|
+
const oneLineDecision = first.value === "decision" && second?.kind === "string" && third?.kind === "ident" && third.value === "because";
|
|
27377
|
+
if (!inSection && first.kind === "ident" && !KEYWORDS.has(first.value) && !oneLineDecision && tokens.slice(1).every((t) => ["string", "tech", "team", "ident", "number", "comma"].includes(t.kind))) {
|
|
26328
27378
|
let k = 1;
|
|
26329
27379
|
const take = (kind) => tokens[k]?.kind === kind ? source(raw, tokens[k++]) : "";
|
|
26330
|
-
const
|
|
26331
|
-
entries.push({ ...base, kind: "node", cells });
|
|
27380
|
+
const cells2 = [first.value, take("string"), take("tech"), take("team"), join2(raw, tokens.slice(k))];
|
|
27381
|
+
entries.push({ ...base, kind: "node", cells: cells2 });
|
|
26332
27382
|
continue;
|
|
26333
27383
|
}
|
|
26334
27384
|
const closes = first.kind === "rbrace";
|
|
@@ -26336,10 +27386,44 @@ function classify(lines) {
|
|
|
26336
27386
|
const code = join2(raw, tokens);
|
|
26337
27387
|
const text = withComment(code, comment);
|
|
26338
27388
|
entries.push({ kind: "line", depth: Math.max(0, depth - (closes ? 1 : 0)), text: sameTokens(text, tokens) ? text : original, opens, closes });
|
|
27389
|
+
if (depth === 0) section = sectionOpenedBy(tokens);
|
|
26339
27390
|
for (const t of tokens) depth = Math.max(0, depth + (t.kind === "lbrace" ? 1 : t.kind === "rbrace" ? -1 : 0));
|
|
27391
|
+
if (depth === 0) section = void 0;
|
|
26340
27392
|
}
|
|
26341
27393
|
return entries;
|
|
26342
27394
|
}
|
|
27395
|
+
function sectionOpenedBy(tokens) {
|
|
27396
|
+
const [first, second] = tokens;
|
|
27397
|
+
if (first.kind !== "ident" || tokens[tokens.length - 1].kind !== "lbrace") return void 0;
|
|
27398
|
+
switch (first.value) {
|
|
27399
|
+
case "traffic":
|
|
27400
|
+
case "requirements":
|
|
27401
|
+
case "capacity":
|
|
27402
|
+
return second?.kind === "lbrace" ? first.value : void 0;
|
|
27403
|
+
case "entity":
|
|
27404
|
+
return second?.kind === "ident" ? "entity" : void 0;
|
|
27405
|
+
case "decision":
|
|
27406
|
+
case "test":
|
|
27407
|
+
return second?.kind === "string" ? first.value : void 0;
|
|
27408
|
+
default:
|
|
27409
|
+
return void 0;
|
|
27410
|
+
}
|
|
27411
|
+
}
|
|
27412
|
+
function sectionCells(section, raw, tokens) {
|
|
27413
|
+
const [first, second] = tokens;
|
|
27414
|
+
if (tokens.some((t) => t.kind === "lbrace" || t.kind === "rbrace" || t.kind === "arrow" || t.kind === "label")) return void 0;
|
|
27415
|
+
const rest = (from) => tokens.length > from ? [join2(raw, tokens.slice(from))] : [];
|
|
27416
|
+
switch (section) {
|
|
27417
|
+
case "traffic":
|
|
27418
|
+
return first.kind === "string" && (second?.kind === "quantity" || second?.kind === "number") ? [source(raw, first), source(raw, second), ...rest(2)] : void 0;
|
|
27419
|
+
case "capacity":
|
|
27420
|
+
return first.kind === "ident" && tokens.length > 1 ? [first.value, ...rest(1)] : void 0;
|
|
27421
|
+
case "entity":
|
|
27422
|
+
return first.kind === "ident" && second?.kind === "ident" ? [first.value, second.value, ...rest(2)] : void 0;
|
|
27423
|
+
default:
|
|
27424
|
+
return void 0;
|
|
27425
|
+
}
|
|
27426
|
+
}
|
|
26343
27427
|
function tidyBlankLines(entries) {
|
|
26344
27428
|
const kept = [];
|
|
26345
27429
|
for (const entry of entries) {
|
|
@@ -26356,7 +27440,7 @@ function tidyBlankLines(entries) {
|
|
|
26356
27440
|
return kept;
|
|
26357
27441
|
}
|
|
26358
27442
|
function renderRun(run) {
|
|
26359
|
-
const codes = run[0].kind === "node" ? alignNodes(run) : alignConnections(run);
|
|
27443
|
+
const codes = run[0].kind === "node" ? alignNodes(run) : run[0].kind === "cells" ? alignCells(run) : alignConnections(run);
|
|
26360
27444
|
const items = run;
|
|
26361
27445
|
const commented = items.filter((e) => e.comment);
|
|
26362
27446
|
const commentCol = commented.length > 1 ? Math.max(...items.map((e, i) => e.comment ? len(codes[i]) : 0)) + 1 : 0;
|
|
@@ -26375,6 +27459,11 @@ function alignNodes(run) {
|
|
|
26375
27459
|
return (line + cells[4]).trimEnd();
|
|
26376
27460
|
});
|
|
26377
27461
|
}
|
|
27462
|
+
function alignCells(run) {
|
|
27463
|
+
const widths = [];
|
|
27464
|
+
for (const { cells } of run) cells.slice(0, -1).forEach((c, i) => widths[i] = Math.max(widths[i] ?? 0, len(c)));
|
|
27465
|
+
return run.map(({ cells }) => cells.map((c, i) => i < cells.length - 1 ? pad(c, widths[i] + 1) : c).join(""));
|
|
27466
|
+
}
|
|
26378
27467
|
function alignConnections(run) {
|
|
26379
27468
|
const head = Math.max(...run.map((e) => len(e.from) + 1 + e.arrow.length));
|
|
26380
27469
|
const labelled = run.filter((e) => e.label !== void 0);
|
|
@@ -26388,8 +27477,8 @@ function alignConnections(run) {
|
|
|
26388
27477
|
function commentOf(raw, tokens) {
|
|
26389
27478
|
const last = tokens[tokens.length - 1];
|
|
26390
27479
|
if (last.kind === "label") {
|
|
26391
|
-
const
|
|
26392
|
-
return
|
|
27480
|
+
const after2 = raw.slice(last.col);
|
|
27481
|
+
return after2.slice(stripTrailingComment(after2).length).trim();
|
|
26393
27482
|
}
|
|
26394
27483
|
return raw.slice(last.col - 1 + last.length).trim();
|
|
26395
27484
|
}
|
|
@@ -26397,7 +27486,7 @@ function join2(raw, tokens) {
|
|
|
26397
27486
|
let out = "";
|
|
26398
27487
|
for (const [i, t] of tokens.entries()) {
|
|
26399
27488
|
const text = t.kind === "label" ? ":" + (t.value ? " " + t.value : "") : source(raw, t);
|
|
26400
|
-
const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma";
|
|
27489
|
+
const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma" && t.kind === "number";
|
|
26401
27490
|
out += (i === 0 || tight ? "" : " ") + text;
|
|
26402
27491
|
}
|
|
26403
27492
|
return out;
|
|
@@ -26411,7 +27500,10 @@ function reindent(line, delta) {
|
|
|
26411
27500
|
const body = line.slice(lead.length).trimEnd();
|
|
26412
27501
|
return body ? " ".repeat(Math.max(0, width(lead) + delta)) + body : "";
|
|
26413
27502
|
}
|
|
26414
|
-
var source = (raw, t) =>
|
|
27503
|
+
var source = (raw, t) => {
|
|
27504
|
+
const text = raw.slice(t.col - 1, t.col - 1 + t.length);
|
|
27505
|
+
return t.kind === "quantity" ? text.replace(/[ \t]+/g, " ") : text;
|
|
27506
|
+
};
|
|
26415
27507
|
var withComment = (code, comment) => comment ? code + " " + comment : code;
|
|
26416
27508
|
var leadingSpace = (line) => /^[ \t]*/.exec(line)[0];
|
|
26417
27509
|
var width = (space) => [...space].reduce((n, ch) => n + (ch === " " ? INDENT.length : 1), 0);
|
|
@@ -26419,6 +27511,92 @@ var indent = (depth) => INDENT.repeat(depth);
|
|
|
26419
27511
|
var len = (s) => [...s].length;
|
|
26420
27512
|
var pad = (s, to) => s + " ".repeat(Math.max(0, to - len(s)));
|
|
26421
27513
|
|
|
27514
|
+
// ../frontend/src/dsl/sequence.ts
|
|
27515
|
+
var PAYLOAD_LIMIT = 40;
|
|
27516
|
+
function shorten(text, max = PAYLOAD_LIMIT) {
|
|
27517
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
27518
|
+
return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}\u2026` : flat;
|
|
27519
|
+
}
|
|
27520
|
+
function requestLabel(step) {
|
|
27521
|
+
const name = !step.httpMethod && step.stepName === `${step.fromServiceId} \u2192 ${step.toServiceId}` ? "" : step.stepName;
|
|
27522
|
+
return [name, step.description, step.requestBody && shorten(step.requestBody)].filter(Boolean).join(" ");
|
|
27523
|
+
}
|
|
27524
|
+
function responseLabel(step) {
|
|
27525
|
+
return [step.statusCode, step.responseBody && shorten(step.responseBody)].filter((p) => p !== void 0 && p !== "").join(" ");
|
|
27526
|
+
}
|
|
27527
|
+
function hasResponse(step) {
|
|
27528
|
+
return !step.failed && (!!step.responseLoc || step.statusCode !== void 0 || !!step.responseBody);
|
|
27529
|
+
}
|
|
27530
|
+
function request(step) {
|
|
27531
|
+
return {
|
|
27532
|
+
kind: "request",
|
|
27533
|
+
from: step.fromServiceId,
|
|
27534
|
+
to: step.toServiceId,
|
|
27535
|
+
number: step.stepOrder + 1,
|
|
27536
|
+
label: requestLabel(step),
|
|
27537
|
+
async: step.executionType !== "SYNC_REQUEST_RESPONSE",
|
|
27538
|
+
failed: !!step.failed,
|
|
27539
|
+
error: !!step.failed,
|
|
27540
|
+
step
|
|
27541
|
+
};
|
|
27542
|
+
}
|
|
27543
|
+
function response(step) {
|
|
27544
|
+
return {
|
|
27545
|
+
kind: "response",
|
|
27546
|
+
from: step.toServiceId,
|
|
27547
|
+
to: step.fromServiceId,
|
|
27548
|
+
number: step.stepOrder + 1,
|
|
27549
|
+
label: responseLabel(step),
|
|
27550
|
+
async: false,
|
|
27551
|
+
failed: false,
|
|
27552
|
+
error: (step.statusCode ?? 0) >= 400,
|
|
27553
|
+
status: step.statusCode,
|
|
27554
|
+
step
|
|
27555
|
+
};
|
|
27556
|
+
}
|
|
27557
|
+
function buildSequence(useCase, scenario, nodes = []) {
|
|
27558
|
+
const items = [];
|
|
27559
|
+
const stack = [];
|
|
27560
|
+
const emit = (message) => items.push({ kind: "message", message });
|
|
27561
|
+
const unwindFor = (from) => {
|
|
27562
|
+
const depth = stack.map((s) => s.toServiceId).lastIndexOf(from);
|
|
27563
|
+
while (stack.length > depth + 1) emit(response(stack.pop()));
|
|
27564
|
+
};
|
|
27565
|
+
const steps = scenario.steps;
|
|
27566
|
+
for (let i = 0; i < steps.length; i++) {
|
|
27567
|
+
const step = steps[i];
|
|
27568
|
+
if (step.parallelGroup === void 0) {
|
|
27569
|
+
unwindFor(step.fromServiceId);
|
|
27570
|
+
emit(request(step));
|
|
27571
|
+
if (hasResponse(step)) stack.push(step);
|
|
27572
|
+
continue;
|
|
27573
|
+
}
|
|
27574
|
+
const group = [];
|
|
27575
|
+
while (i < steps.length && steps[i].parallelGroup === step.parallelGroup) group.push(steps[i++]);
|
|
27576
|
+
i--;
|
|
27577
|
+
unwindFor(step.fromServiceId);
|
|
27578
|
+
items.push({ kind: "par", branches: group.map((s) => hasResponse(s) ? [request(s), response(s)] : [request(s)]) });
|
|
27579
|
+
}
|
|
27580
|
+
while (stack.length) emit(response(stack.pop()));
|
|
27581
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
27582
|
+
const participants = [];
|
|
27583
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27584
|
+
const add = (id) => {
|
|
27585
|
+
if (seen.has(id)) return;
|
|
27586
|
+
seen.add(id);
|
|
27587
|
+
const node = byId.get(id);
|
|
27588
|
+
participants.push({ id, name: node?.name ?? id, tech: node && !node.implicit ? node.techStack : void 0 });
|
|
27589
|
+
};
|
|
27590
|
+
for (const step of steps) {
|
|
27591
|
+
add(step.fromServiceId);
|
|
27592
|
+
add(step.toServiceId);
|
|
27593
|
+
}
|
|
27594
|
+
return { useCase, scenario, participants, items, condition: scenario.condition || void 0 };
|
|
27595
|
+
}
|
|
27596
|
+
function sequenceMessages(items) {
|
|
27597
|
+
return items.flatMap((item) => item.kind === "message" ? [item.message] : item.branches.flat());
|
|
27598
|
+
}
|
|
27599
|
+
|
|
26422
27600
|
// ../frontend/src/dsl/mermaid.ts
|
|
26423
27601
|
var RESERVED = new Set(
|
|
26424
27602
|
[
|
|
@@ -26468,6 +27646,992 @@ var RESERVED = new Set(
|
|
|
26468
27646
|
].map((w) => w.toLowerCase())
|
|
26469
27647
|
);
|
|
26470
27648
|
|
|
27649
|
+
// ../frontend/src/sim/access.ts
|
|
27650
|
+
function stepAccess(step) {
|
|
27651
|
+
return step.access ?? accessOf(step.httpMethod, step.stepName ?? "");
|
|
27652
|
+
}
|
|
27653
|
+
function accessIn(diagram) {
|
|
27654
|
+
const queues = new Set(diagram.nodes.filter((n) => kindOf(n) === "queue").map((n) => n.id));
|
|
27655
|
+
return (step) => queues.has(step.toServiceId) ? "write" : stepAccess(step);
|
|
27656
|
+
}
|
|
27657
|
+
|
|
27658
|
+
// ../frontend/src/sim/profiles.ts
|
|
27659
|
+
var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
|
|
27660
|
+
var RELATIONAL = {
|
|
27661
|
+
rps: 2e4,
|
|
27662
|
+
readRps: 2e4,
|
|
27663
|
+
writeRps: 5e3,
|
|
27664
|
+
writeScaling: "shards",
|
|
27665
|
+
latencyMs: 5,
|
|
27666
|
+
availability: 0.9995,
|
|
27667
|
+
costUsd: 400,
|
|
27668
|
+
durable: true,
|
|
27669
|
+
consistency: "strong"
|
|
27670
|
+
};
|
|
27671
|
+
var KIND_PROFILES = {
|
|
27672
|
+
client: { ...NEUTRAL, bandwidthMBps: 10 },
|
|
27673
|
+
edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
27674
|
+
cdn: { rps: 2e5, latencyMs: 5, availability: 0.9999, costUsd: 100, durable: false, egressUsdPerGb: 0.02 },
|
|
27675
|
+
loadbalancer: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
27676
|
+
gateway: { rps: 1e4, latencyMs: 10, availability: 0.9995, costUsd: 100, durable: false },
|
|
27677
|
+
// Name resolution happens before the request and is cached: off the request path.
|
|
27678
|
+
dns: { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false },
|
|
27679
|
+
service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
|
|
27680
|
+
function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
|
|
27681
|
+
cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false, consistency: "eventual" },
|
|
27682
|
+
database: RELATIONAL,
|
|
27683
|
+
search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true, consistency: "eventual" },
|
|
27684
|
+
analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true, consistency: "eventual" },
|
|
27685
|
+
queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true, consistency: "strong" },
|
|
27686
|
+
storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true, consistency: "eventual", egressUsdPerGb: 0.09 },
|
|
27687
|
+
external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
|
|
27688
|
+
other: NEUTRAL
|
|
27689
|
+
};
|
|
27690
|
+
var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true, consistency: "eventual" };
|
|
27691
|
+
var NOSQL_STRONG = { ...NOSQL, consistency: "strong" };
|
|
27692
|
+
var TECH_PROFILES = {
|
|
27693
|
+
DynamoDB: NOSQL,
|
|
27694
|
+
"AWS DynamoDB": NOSQL,
|
|
27695
|
+
Cassandra: NOSQL,
|
|
27696
|
+
CouchDB: NOSQL,
|
|
27697
|
+
"Azure Cosmos DB": NOSQL,
|
|
27698
|
+
MongoDB: NOSQL_STRONG,
|
|
27699
|
+
"GCP Bigtable": NOSQL_STRONG,
|
|
27700
|
+
"GCP Firestore": NOSQL_STRONG,
|
|
27701
|
+
"GCP Spanner": NOSQL_STRONG
|
|
27702
|
+
};
|
|
27703
|
+
var BANDWIDTH = {
|
|
27704
|
+
client: 10,
|
|
27705
|
+
edge: 1e3,
|
|
27706
|
+
cdn: 1e3,
|
|
27707
|
+
loadbalancer: 1e3,
|
|
27708
|
+
gateway: 1e3,
|
|
27709
|
+
dns: 1e3,
|
|
27710
|
+
service: 200
|
|
27711
|
+
};
|
|
27712
|
+
var DEFAULT_BANDWIDTH_MBPS = 100;
|
|
27713
|
+
function profileOf(node, override) {
|
|
27714
|
+
const kind = kindOf(node);
|
|
27715
|
+
const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
|
|
27716
|
+
const baseRead = base.readRps ?? base.rps;
|
|
27717
|
+
const baseWrite = base.writeRps ?? base.rps;
|
|
27718
|
+
const readRps = override?.readRps ?? override?.rps ?? baseRead;
|
|
27719
|
+
const writeRps = override?.writeRps ?? override?.rps ?? baseWrite;
|
|
27720
|
+
const consistency = override?.consistency ?? base.consistency;
|
|
27721
|
+
return {
|
|
27722
|
+
kind,
|
|
27723
|
+
rps: readRps,
|
|
27724
|
+
readRps,
|
|
27725
|
+
writeRps,
|
|
27726
|
+
writeScaling: base.writeScaling ?? "replicas",
|
|
27727
|
+
shards: Math.max(1, Math.floor(override?.shards ?? 1)),
|
|
27728
|
+
latencyMs: override?.latencyMs ?? base.latencyMs,
|
|
27729
|
+
availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
|
|
27730
|
+
costUsd: override?.costUsd ?? base.costUsd,
|
|
27731
|
+
durable: override?.durable ?? base.durable,
|
|
27732
|
+
...consistency && isDataStore(kind) ? { consistency } : {},
|
|
27733
|
+
bandwidthMBps: override?.bandwidthMBps ?? base.bandwidthMBps ?? BANDWIDTH[kind] ?? DEFAULT_BANDWIDTH_MBPS,
|
|
27734
|
+
egressUsdPerGb: override?.egressUsdPerGb ?? base.egressUsdPerGb ?? 0
|
|
27735
|
+
};
|
|
27736
|
+
}
|
|
27737
|
+
function capacityOf(p, replicas) {
|
|
27738
|
+
return {
|
|
27739
|
+
readRps: p.readRps * replicas * p.shards,
|
|
27740
|
+
writeRps: p.writeRps * (p.writeScaling === "shards" ? 1 : replicas) * p.shards
|
|
27741
|
+
};
|
|
27742
|
+
}
|
|
27743
|
+
function combinedUtilization(p, readUtilization, writeUtilization) {
|
|
27744
|
+
return p.writeScaling === "shards" ? Math.max(readUtilization, writeUtilization) : readUtilization + writeUtilization;
|
|
27745
|
+
}
|
|
27746
|
+
|
|
27747
|
+
// ../frontend/src/sim/flow.ts
|
|
27748
|
+
function findUseCase(diagram, name) {
|
|
27749
|
+
const lower = name.toLowerCase();
|
|
27750
|
+
return diagram.useCases.find((u) => u.name === name) ?? diagram.useCases.find((u) => u.name.toLowerCase() === lower) ?? diagram.useCases.find((u) => u.id === name);
|
|
27751
|
+
}
|
|
27752
|
+
function findScenario2(useCase, name) {
|
|
27753
|
+
const norm = (s) => s.replace(/\s*(›|>)\s*/g, " \u203A ").trim().toLowerCase();
|
|
27754
|
+
const wanted = norm(name);
|
|
27755
|
+
return useCase.scenarios.find((s) => s.name === name) ?? useCase.scenarios.find((s) => norm(s.name) === wanted || s.id === name);
|
|
27756
|
+
}
|
|
27757
|
+
function afterEntryResponse(entry, step) {
|
|
27758
|
+
const r = entry.responseLoc;
|
|
27759
|
+
if (!r || step === entry || step.loc.file !== r.file) return false;
|
|
27760
|
+
return step.loc.line > r.line || step.loc.line === r.line && step.loc.col > r.col;
|
|
27761
|
+
}
|
|
27762
|
+
function criticalPath(useCase, scenario) {
|
|
27763
|
+
const entry = scenario.steps[0];
|
|
27764
|
+
if (!entry) return [];
|
|
27765
|
+
const { items } = buildSequence(useCase, scenario);
|
|
27766
|
+
const working = /* @__PURE__ */ new Map([[entry.fromServiceId, 1]]);
|
|
27767
|
+
const opened = /* @__PURE__ */ new Set();
|
|
27768
|
+
let answered = false;
|
|
27769
|
+
const parts = [];
|
|
27770
|
+
const visit = (m, hops) => {
|
|
27771
|
+
if (m.kind === "response") {
|
|
27772
|
+
if (opened.delete(m.step)) working.set(m.step.toServiceId, (working.get(m.step.toServiceId) ?? 1) - 1);
|
|
27773
|
+
if (m.step === entry) answered = true;
|
|
27774
|
+
return;
|
|
27775
|
+
}
|
|
27776
|
+
if (afterEntryResponse(entry, m.step)) answered = true;
|
|
27777
|
+
if (answered || !working.get(m.from)) return;
|
|
27778
|
+
hops.push({ step: m.step, target: m.to, failed: m.failed, async: m.async });
|
|
27779
|
+
if (!m.async && !m.failed) {
|
|
27780
|
+
opened.add(m.step);
|
|
27781
|
+
working.set(m.to, (working.get(m.to) ?? 0) + 1);
|
|
27782
|
+
}
|
|
27783
|
+
};
|
|
27784
|
+
for (const item of items) {
|
|
27785
|
+
const hops = [];
|
|
27786
|
+
if (item.kind === "message") visit(item.message, hops);
|
|
27787
|
+
else for (const branch of item.branches) for (const m of branch) visit(m, hops);
|
|
27788
|
+
if (hops.length) parts.push(hops);
|
|
27789
|
+
}
|
|
27790
|
+
return parts;
|
|
27791
|
+
}
|
|
27792
|
+
function pathNodes(useCase, scenario) {
|
|
27793
|
+
const entry = scenario.steps[0];
|
|
27794
|
+
if (!entry) return [];
|
|
27795
|
+
const ids = [entry.fromServiceId, ...criticalPath(useCase, scenario).flatMap((part) => part.filter((h) => !h.failed).map((h) => h.target))];
|
|
27796
|
+
return [...new Set(ids)];
|
|
27797
|
+
}
|
|
27798
|
+
function requestOrder(useCase, scenario) {
|
|
27799
|
+
const entry = scenario.steps[0];
|
|
27800
|
+
const messages = sequenceMessages(buildSequence(useCase, scenario).items);
|
|
27801
|
+
const requests = [];
|
|
27802
|
+
let beforeResponse = -1;
|
|
27803
|
+
for (const m of messages) {
|
|
27804
|
+
if (m.kind === "request") {
|
|
27805
|
+
if (beforeResponse < 0 && entry && afterEntryResponse(entry, m.step)) beforeResponse = requests.length;
|
|
27806
|
+
requests.push(m);
|
|
27807
|
+
} else if (m.step === entry && beforeResponse < 0) beforeResponse = requests.length;
|
|
27808
|
+
}
|
|
27809
|
+
return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
|
|
27810
|
+
}
|
|
27811
|
+
function writesBeforeResponse(useCase, scenario, accept, isWrite = (step) => stepAccess(step) === "write") {
|
|
27812
|
+
const { requests, beforeResponse } = requestOrder(useCase, scenario);
|
|
27813
|
+
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to) && isWrite(m.step));
|
|
27814
|
+
}
|
|
27815
|
+
function callsOk(scenario, nodeId) {
|
|
27816
|
+
return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
|
|
27817
|
+
}
|
|
27818
|
+
function needs(useCase, nodeId) {
|
|
27819
|
+
return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
|
|
27820
|
+
}
|
|
27821
|
+
function fallbacksFor(useCase, nodeId) {
|
|
27822
|
+
return useCase.scenarios.filter((s) => {
|
|
27823
|
+
if (s.outcome !== "success") return false;
|
|
27824
|
+
const { requests, beforeResponse } = requestOrder(useCase, s);
|
|
27825
|
+
const early = requests.slice(0, beforeResponse).filter((m) => m.to === nodeId);
|
|
27826
|
+
const window = early.some((m) => m.failed) ? early : requests.filter((m) => m.to === nodeId);
|
|
27827
|
+
return window.some((m) => m.failed) && !window.some((m) => !m.failed && !m.async);
|
|
27828
|
+
});
|
|
27829
|
+
}
|
|
27830
|
+
function selectorText(selector) {
|
|
27831
|
+
if ("node" in selector) return selector.node;
|
|
27832
|
+
if ("tech" in selector) return `[${selector.tech}]`;
|
|
27833
|
+
if ("anyOf" in selector) return selector.anyOf.map(selectorText).join(" or ");
|
|
27834
|
+
if ("consistency" in selector) return `any ${selector.consistency} store`;
|
|
27835
|
+
return `any ${selector.kind}`;
|
|
27836
|
+
}
|
|
27837
|
+
function selectNodes(diagram, selector) {
|
|
27838
|
+
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector, diagram));
|
|
27839
|
+
}
|
|
27840
|
+
function matches(node, selector, diagram) {
|
|
27841
|
+
if (node.kind !== "component") return false;
|
|
27842
|
+
if ("node" in selector) return node.id === selector.node;
|
|
27843
|
+
if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
|
|
27844
|
+
if ("anyOf" in selector) return selector.anyOf.some((s) => matches(node, s, diagram));
|
|
27845
|
+
if ("consistency" in selector) {
|
|
27846
|
+
const override = diagram?.capacity?.find((o) => o.node === node.id);
|
|
27847
|
+
return profileOf(node, override).consistency === selector.consistency;
|
|
27848
|
+
}
|
|
27849
|
+
return kindMatches(kindOf(node), selector.kind);
|
|
27850
|
+
}
|
|
27851
|
+
function techOf(node) {
|
|
27852
|
+
return node && !node.implicit ? node.techStack : void 0;
|
|
27853
|
+
}
|
|
27854
|
+
function nodeLabel(diagram, id) {
|
|
27855
|
+
const tech = techOf(diagram.nodes.find((n) => n.id === id));
|
|
27856
|
+
return tech ? `${id} (${tech})` : id;
|
|
27857
|
+
}
|
|
27858
|
+
|
|
27859
|
+
// ../frontend/src/sim/format.ts
|
|
27860
|
+
var trim = (n, digits) => String(Number(n.toFixed(digits)));
|
|
27861
|
+
function formatRps(rps) {
|
|
27862
|
+
if (!Number.isFinite(rps)) return "\u221E rps";
|
|
27863
|
+
if (rps >= 1e6) return `${trim(rps / 1e6, 1)}m rps`;
|
|
27864
|
+
if (rps >= 1e3) return `${trim(rps / 1e3, 1)}k rps`;
|
|
27865
|
+
return `${trim(rps, rps < 10 ? 2 : 0)} rps`;
|
|
27866
|
+
}
|
|
27867
|
+
function formatMs(ms) {
|
|
27868
|
+
return `${trim(ms, ms < 100 ? 1 : 0)} ms`;
|
|
27869
|
+
}
|
|
27870
|
+
function formatAvailability(fraction) {
|
|
27871
|
+
if (fraction >= 1) return "100%";
|
|
27872
|
+
const down = (1 - fraction) * 100;
|
|
27873
|
+
const digits = Math.min(6, Math.max(1, Math.ceil(-Math.log10(down)) + 1));
|
|
27874
|
+
return `${trim(Math.floor(fraction * 100 * 10 ** digits + 1e-6) / 10 ** digits, digits)}%`;
|
|
27875
|
+
}
|
|
27876
|
+
function formatPercent(fraction) {
|
|
27877
|
+
return `${Math.round(fraction * 100)}%`;
|
|
27878
|
+
}
|
|
27879
|
+
function formatUsd(usd) {
|
|
27880
|
+
return `$${Math.round(usd).toLocaleString("en-US")}/month`;
|
|
27881
|
+
}
|
|
27882
|
+
|
|
27883
|
+
// ../frontend/src/sim/analyze.ts
|
|
27884
|
+
var DEFAULT_TIMEOUT_MS = 1e3;
|
|
27885
|
+
var HOT = 0.7;
|
|
27886
|
+
var QUEUE_CAP = 0.95;
|
|
27887
|
+
var SECONDS_PER_MONTH = 2592e3;
|
|
27888
|
+
var BYTES_PER_MB = 1e6;
|
|
27889
|
+
var BYTES_PER_GB = 1e9;
|
|
27890
|
+
var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
|
|
27891
|
+
var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
|
|
27892
|
+
var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
|
|
27893
|
+
function components(diagram) {
|
|
27894
|
+
return diagram.nodes.filter((n) => n.kind === "component");
|
|
27895
|
+
}
|
|
27896
|
+
var replicasOf = (node) => Math.max(1, node.replicas ?? 1);
|
|
27897
|
+
function resolveTraffic(diagram, warnings = []) {
|
|
27898
|
+
const result = /* @__PURE__ */ new Map();
|
|
27899
|
+
for (const u of diagram.useCases) result.set(u.id, { rps: 0, shares: u.scenarios.map((_, i) => i === 0 ? 1 : 0) });
|
|
27900
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27901
|
+
for (const entry of diagram.traffic ?? []) {
|
|
27902
|
+
const useCase = findUseCase(diagram, entry.useCase);
|
|
27903
|
+
if (!useCase) {
|
|
27904
|
+
warnings.push(`Traffic for unknown use case "${entry.useCase}" is ignored`);
|
|
27905
|
+
continue;
|
|
27906
|
+
}
|
|
27907
|
+
if (seen.has(useCase.id)) continue;
|
|
27908
|
+
seen.add(useCase.id);
|
|
27909
|
+
const shares = useCase.scenarios.map(() => 0);
|
|
27910
|
+
for (const { scenario, share } of entry.mix ?? []) {
|
|
27911
|
+
const found = findScenario2(useCase, scenario);
|
|
27912
|
+
if (found) shares[useCase.scenarios.indexOf(found)] += share;
|
|
27913
|
+
else warnings.push(`"${useCase.name}" has no scenario "${scenario}"; its traffic share is ignored`);
|
|
27914
|
+
}
|
|
27915
|
+
const total = shares.reduce((a, b) => a + b, 0);
|
|
27916
|
+
if (total <= 0) shares[0] = 1;
|
|
27917
|
+
else {
|
|
27918
|
+
if (Math.abs(total - 1) > 1e-6) warnings.push(`Traffic mix of "${useCase.name}" adds up to ${formatPercent(total)}; shares are scaled to 100%`);
|
|
27919
|
+
for (let i = 0; i < shares.length; i++) shares[i] /= total;
|
|
27920
|
+
}
|
|
27921
|
+
result.set(useCase.id, { rps: entry.rps, shares: useCase.scenarios.length ? shares : [] });
|
|
27922
|
+
}
|
|
27923
|
+
return result;
|
|
27924
|
+
}
|
|
27925
|
+
var multiplierOf = (step) => step.multiplier !== void 0 && step.multiplier > 0 ? step.multiplier : 1;
|
|
27926
|
+
function forEachStepRate(diagram, traffic, visit) {
|
|
27927
|
+
for (const u of diagram.useCases) {
|
|
27928
|
+
const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
|
|
27929
|
+
u.scenarios.forEach((s, i) => {
|
|
27930
|
+
const rate = rps * (shares[i] ?? 0);
|
|
27931
|
+
if (rate > 0) for (const step of s.steps) visit(step, rate);
|
|
27932
|
+
});
|
|
27933
|
+
}
|
|
27934
|
+
}
|
|
27935
|
+
function computeLoad(diagram, traffic) {
|
|
27936
|
+
const load = /* @__PURE__ */ new Map();
|
|
27937
|
+
const access = accessIn(diagram);
|
|
27938
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
27939
|
+
const entry = load.get(step.toServiceId) ?? { read: 0, write: 0 };
|
|
27940
|
+
entry[access(step)] += rate * multiplierOf(step);
|
|
27941
|
+
load.set(step.toServiceId, entry);
|
|
27942
|
+
});
|
|
27943
|
+
return load;
|
|
27944
|
+
}
|
|
27945
|
+
var egressSource = (step, access) => access === "read" ? step.toServiceId : step.fromServiceId;
|
|
27946
|
+
function computeEgressGb(diagram, traffic) {
|
|
27947
|
+
const gb = /* @__PURE__ */ new Map();
|
|
27948
|
+
const access = accessIn(diagram);
|
|
27949
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
27950
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return;
|
|
27951
|
+
const source2 = egressSource(step, access(step));
|
|
27952
|
+
gb.set(source2, (gb.get(source2) ?? 0) + rate * multiplierOf(step) * step.sizeBytes * SECONDS_PER_MONTH / BYTES_PER_GB);
|
|
27953
|
+
});
|
|
27954
|
+
return gb;
|
|
27955
|
+
}
|
|
27956
|
+
function transferMs(step, bandwidthOf) {
|
|
27957
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return 0;
|
|
27958
|
+
const mbps = Math.min(bandwidthOf(step.fromServiceId), bandwidthOf(step.toServiceId));
|
|
27959
|
+
return mbps > 0 && Number.isFinite(mbps) ? step.sizeBytes / BYTES_PER_MB / mbps * 1e3 : 0;
|
|
27960
|
+
}
|
|
27961
|
+
var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
|
|
27962
|
+
var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
|
|
27963
|
+
var replicatedAvailability = (a, replicas) => 1 - (1 - a) ** replicas;
|
|
27964
|
+
function usageOf(p, replicas, readLoadRps, writeLoadRps) {
|
|
27965
|
+
const { readRps: readCapacityRps, writeRps: writeCapacityRps } = capacityOf(p, replicas);
|
|
27966
|
+
const readUtilization = utilizationOf(readLoadRps, readCapacityRps);
|
|
27967
|
+
const writeUtilization = utilizationOf(writeLoadRps, writeCapacityRps);
|
|
27968
|
+
const utilization = combinedUtilization(p, readUtilization, writeUtilization);
|
|
27969
|
+
const loadRps = readLoadRps + writeLoadRps;
|
|
27970
|
+
return {
|
|
27971
|
+
loadRps,
|
|
27972
|
+
capacityRps: readCapacityRps === writeCapacityRps || utilization === 0 ? readCapacityRps : loadRps / utilization,
|
|
27973
|
+
utilization,
|
|
27974
|
+
saturated: utilization >= 1,
|
|
27975
|
+
readLoadRps,
|
|
27976
|
+
writeLoadRps,
|
|
27977
|
+
readCapacityRps,
|
|
27978
|
+
writeCapacityRps,
|
|
27979
|
+
readUtilization,
|
|
27980
|
+
writeUtilization,
|
|
27981
|
+
writeScaling: p.writeScaling
|
|
27982
|
+
};
|
|
27983
|
+
}
|
|
27984
|
+
function usageText(n) {
|
|
27985
|
+
const part = (load, capacity, rho) => `${formatRps(load)} of ${formatRps(capacity)}, ${formatPercent(rho)}`;
|
|
27986
|
+
if (n.writeScaling === "shards" && n.writeLoadRps > 0) {
|
|
27987
|
+
return `reads ${part(n.readLoadRps, n.readCapacityRps, n.readUtilization)}; writes ${part(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization)}`;
|
|
27988
|
+
}
|
|
27989
|
+
return part(n.loadRps, n.capacityRps, n.utilization);
|
|
27990
|
+
}
|
|
27991
|
+
var writeBound = (n) => n.writeScaling === "shards" && n.writeUtilization >= n.readUtilization && n.writeLoadRps > 0;
|
|
27992
|
+
function useCasePercentile(scenarios, q) {
|
|
27993
|
+
const tail = 1 - q / 100 - 1e-9;
|
|
27994
|
+
const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
|
|
27995
|
+
if (heavy.length) return Math.max(...heavy.map((s) => s.value));
|
|
27996
|
+
let covered = 0;
|
|
27997
|
+
for (const s of [...scenarios].sort((a, b) => b.value - a.value)) {
|
|
27998
|
+
covered += s.share;
|
|
27999
|
+
if (covered >= tail) return s.value;
|
|
28000
|
+
}
|
|
28001
|
+
return scenarios.length ? Math.max(...scenarios.map((s) => s.value)) : 0;
|
|
28002
|
+
}
|
|
28003
|
+
function mainScenarioIndex(shares) {
|
|
28004
|
+
let best = 0;
|
|
28005
|
+
shares.forEach((share, i) => {
|
|
28006
|
+
if (share > shares[best]) best = i;
|
|
28007
|
+
});
|
|
28008
|
+
return best;
|
|
28009
|
+
}
|
|
28010
|
+
function profilesOf(diagram, warnings = []) {
|
|
28011
|
+
const nodes = components(diagram);
|
|
28012
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
28013
|
+
const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
|
|
28014
|
+
for (const o of diagram.capacity ?? []) if (!ids.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
|
|
28015
|
+
return new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
|
|
28016
|
+
}
|
|
28017
|
+
function analyze(diagram, options = {}) {
|
|
28018
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
28019
|
+
const warnings = [];
|
|
28020
|
+
const nodes = components(diagram);
|
|
28021
|
+
const profiles = profilesOf(diagram, warnings);
|
|
28022
|
+
const traffic = resolveTraffic(diagram, warnings);
|
|
28023
|
+
const load = computeLoad(diagram, traffic);
|
|
28024
|
+
const egress = computeEgressGb(diagram, traffic);
|
|
28025
|
+
const nodeResults = nodes.map((n) => {
|
|
28026
|
+
const p = profiles.get(n.id);
|
|
28027
|
+
const replicas = replicasOf(n);
|
|
28028
|
+
const { read, write } = load.get(n.id) ?? { read: 0, write: 0 };
|
|
28029
|
+
const usage = usageOf(p, replicas, read, write);
|
|
28030
|
+
const egressGbPerMonth = egress.get(n.id) ?? 0;
|
|
28031
|
+
const egressUsd = egressGbPerMonth * p.egressUsdPerGb;
|
|
28032
|
+
return {
|
|
28033
|
+
id: n.id,
|
|
28034
|
+
kind: p.kind,
|
|
28035
|
+
replicas,
|
|
28036
|
+
shards: p.shards,
|
|
28037
|
+
...usage,
|
|
28038
|
+
...p.consistency ? { consistency: p.consistency } : {},
|
|
28039
|
+
latencyMs: hopLatency(p.latencyMs, usage.utilization),
|
|
28040
|
+
availability: replicatedAvailability(p.availability, replicas),
|
|
28041
|
+
costUsd: replicas * p.shards * p.costUsd + egressUsd,
|
|
28042
|
+
egressGbPerMonth,
|
|
28043
|
+
egressUsd,
|
|
28044
|
+
durable: p.durable
|
|
28045
|
+
};
|
|
28046
|
+
});
|
|
28047
|
+
const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
|
|
28048
|
+
const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
|
|
28049
|
+
const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
|
|
28050
|
+
const bandwidthOf = (id) => profiles.get(id)?.bandwidthMBps ?? Infinity;
|
|
28051
|
+
for (const n of nodeResults) {
|
|
28052
|
+
const usage = usageText(n);
|
|
28053
|
+
if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. ${writeBound(n) ? "Add shards for writes" : "Add replicas"} or take load off it.`);
|
|
28054
|
+
else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
|
|
28055
|
+
}
|
|
28056
|
+
const useCases = diagram.useCases.map((u) => {
|
|
28057
|
+
const { rps, shares } = traffic.get(u.id);
|
|
28058
|
+
const scenarios = u.scenarios.map((s, i) => {
|
|
28059
|
+
const meanMs = criticalPath(u, s).reduce(
|
|
28060
|
+
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target) + transferMs(hop.step, bandwidthOf))),
|
|
28061
|
+
0
|
|
28062
|
+
);
|
|
28063
|
+
return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
|
|
28064
|
+
});
|
|
28065
|
+
const percentiles = Object.fromEntries(
|
|
28066
|
+
PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], useCasePercentile(scenarios.map((s) => ({ share: s.share, value: s.percentiles[PERCENTILE_KEYS[q]] })), q)])
|
|
28067
|
+
);
|
|
28068
|
+
if (rps > 0 && u.scenarios.length === 0) warnings.push(`"${u.name}" has traffic but no steps`);
|
|
28069
|
+
return { id: u.id, name: u.name, rps, scenarios, percentiles, availability: useCaseAvailability(u, shares, availabilityOf) };
|
|
28070
|
+
});
|
|
28071
|
+
return {
|
|
28072
|
+
nodes: nodeResults,
|
|
28073
|
+
useCases,
|
|
28074
|
+
totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
|
|
28075
|
+
totalEgressUsd: nodeResults.reduce((sum, n) => sum + n.egressUsd, 0),
|
|
28076
|
+
singlePointsOfFailure: singlePointsOfFailure(diagram),
|
|
28077
|
+
warnings
|
|
28078
|
+
};
|
|
28079
|
+
}
|
|
28080
|
+
function percentilesOf(meanMs) {
|
|
28081
|
+
return Object.fromEntries(PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], meanMs * PERCENTILE_FACTORS[q]]));
|
|
28082
|
+
}
|
|
28083
|
+
function useCaseAvailability(useCase, shares, availabilityOf) {
|
|
28084
|
+
if (useCase.scenarios.length === 0) return 1;
|
|
28085
|
+
const main = pathNodes(useCase, useCase.scenarios[mainScenarioIndex(shares)]);
|
|
28086
|
+
const onMain = new Set(main);
|
|
28087
|
+
let result = 1;
|
|
28088
|
+
for (const id of main) {
|
|
28089
|
+
const a = availabilityOf(id);
|
|
28090
|
+
const fallback = Math.max(
|
|
28091
|
+
0,
|
|
28092
|
+
...fallbacksFor(useCase, id).map(
|
|
28093
|
+
(s) => pathNodes(useCase, s).filter((other) => other !== id && !onMain.has(other)).reduce((product, other) => product * availabilityOf(other), 1)
|
|
28094
|
+
)
|
|
28095
|
+
);
|
|
28096
|
+
result *= 1 - (1 - a) * (1 - fallback);
|
|
28097
|
+
}
|
|
28098
|
+
return result;
|
|
28099
|
+
}
|
|
28100
|
+
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external" && kind !== "dns";
|
|
28101
|
+
function singlePointsOfFailure(diagram) {
|
|
28102
|
+
return components(diagram).filter((n) => replicasOf(n) === 1 && DEPLOYED(profileOf(n).kind)).filter((n) => diagram.useCases.some((u) => needs(u, n.id) && fallbacksFor(u, n.id).length === 0)).map((n) => n.id);
|
|
28103
|
+
}
|
|
28104
|
+
|
|
28105
|
+
// ../frontend/src/sim/tests.ts
|
|
28106
|
+
var pass = (message) => ({ passed: true, message });
|
|
28107
|
+
var fail = (message, hint) => ({ passed: false, message, hint });
|
|
28108
|
+
function runTests(diagram, analysis = analyze(diagram)) {
|
|
28109
|
+
const access = accessIn(diagram);
|
|
28110
|
+
const run = {
|
|
28111
|
+
diagram,
|
|
28112
|
+
analysis,
|
|
28113
|
+
nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
|
|
28114
|
+
useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
|
|
28115
|
+
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares])),
|
|
28116
|
+
profiles: profilesOf(diagram),
|
|
28117
|
+
isWrite: (step) => access(step) === "write"
|
|
28118
|
+
};
|
|
28119
|
+
const results = (diagram.requirements ?? []).map((r, i) => {
|
|
28120
|
+
const { passed, message, hint } = requirement(run, r);
|
|
28121
|
+
return { id: `req:${i + 1}`, name: requirementName(r), category: CATEGORY[r.kind], passed, message, hint, loc: r.loc };
|
|
28122
|
+
});
|
|
28123
|
+
const names = /* @__PURE__ */ new Map();
|
|
28124
|
+
for (const test of diagram.tests ?? []) {
|
|
28125
|
+
const n = (names.get(test.name) ?? 0) + 1;
|
|
28126
|
+
names.set(test.name, n);
|
|
28127
|
+
results.push({ id: n === 1 ? `test:${test.name}` : `test:${test.name}#${n}`, name: test.name, category: "flow", ...flowTest(run, test), loc: test.loc });
|
|
28128
|
+
}
|
|
28129
|
+
return results.map(({ hint, ...r }) => r.passed || !hint ? r : { ...r, hint });
|
|
28130
|
+
}
|
|
28131
|
+
var CATEGORY = {
|
|
28132
|
+
latency: "latency",
|
|
28133
|
+
availability: "availability",
|
|
28134
|
+
durable: "durability",
|
|
28135
|
+
survive: "resilience",
|
|
28136
|
+
cost: "cost"
|
|
28137
|
+
};
|
|
28138
|
+
var percentileName = (q) => PERCENTILE_KEYS[q];
|
|
28139
|
+
var lineOf = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
|
|
28140
|
+
function stepText(step) {
|
|
28141
|
+
const arrow = step.failed ? "-x" : step.executionType === "SYNC_REQUEST_RESPONSE" ? "->" : "->>";
|
|
28142
|
+
const label = requestLabel(step);
|
|
28143
|
+
return `${step.fromServiceId} ${arrow} ${step.toServiceId}${label ? ` : ${label}` : ""} at ${lineOf(step.loc)}`;
|
|
28144
|
+
}
|
|
28145
|
+
function requirementName(r) {
|
|
28146
|
+
switch (r.kind) {
|
|
28147
|
+
case "latency":
|
|
28148
|
+
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"}${r.scenario !== void 0 ? ` scenario ${r.scenario}` : ""} < ${formatMs(r.maxMs)}`;
|
|
28149
|
+
case "availability":
|
|
28150
|
+
return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
|
|
28151
|
+
case "durable":
|
|
28152
|
+
return `${r.useCase} is durable`;
|
|
28153
|
+
case "survive":
|
|
28154
|
+
return r.target === "any" ? "survive any node failure" : `survive failure of ${selectorText(r.target)}`;
|
|
28155
|
+
case "cost":
|
|
28156
|
+
return `cost \u2264 ${formatUsd(r.maxUsdPerMonth)}`;
|
|
28157
|
+
}
|
|
28158
|
+
}
|
|
28159
|
+
function requirement(run, r) {
|
|
28160
|
+
switch (r.kind) {
|
|
28161
|
+
case "latency":
|
|
28162
|
+
if (r.useCase !== void 0 && r.scenario !== void 0) return scenarioLatency(run, r.useCase, r.scenario, r.percentile, r.maxMs);
|
|
28163
|
+
return overUseCases(run, r.useCase, (u) => latency(run, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
|
|
28164
|
+
case "availability":
|
|
28165
|
+
return overUseCases(run, r.useCase, (u) => availability(run, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
|
|
28166
|
+
case "durable":
|
|
28167
|
+
return durable(run, r.useCase);
|
|
28168
|
+
case "survive":
|
|
28169
|
+
return survive(run, r.target);
|
|
28170
|
+
case "cost":
|
|
28171
|
+
return cost(run, r.maxUsdPerMonth);
|
|
28172
|
+
}
|
|
28173
|
+
}
|
|
28174
|
+
function overUseCases(run, name, check, limit, anyWithoutTraffic = false) {
|
|
28175
|
+
if (name !== void 0) {
|
|
28176
|
+
const u = findUseCase(run.diagram, name);
|
|
28177
|
+
return u ? check(u) : unknownUseCase(run, name);
|
|
28178
|
+
}
|
|
28179
|
+
let targets = run.diagram.useCases.filter((u) => (run.useCases.get(u.id)?.rps ?? 0) > 0);
|
|
28180
|
+
if (targets.length === 0 && anyWithoutTraffic) targets = run.diagram.useCases;
|
|
28181
|
+
if (targets.length === 0) return fail("No use case has traffic, so there is nothing to measure", 'Add traffic { "<use case>" 1k rps } for the use cases this applies to');
|
|
28182
|
+
const checks = targets.map(check);
|
|
28183
|
+
const failed = checks.filter((c) => !c.passed);
|
|
28184
|
+
if (failed.length === 0) {
|
|
28185
|
+
return pass(checks.length === 1 ? checks[0].message : `All ${checks.length} use cases hold; ${checks.map((c) => c.value).join(", ")} ${limit}`);
|
|
28186
|
+
}
|
|
28187
|
+
return fail(failed.length === 1 ? failed[0].message : `${failed.map((c) => c.value ?? c.message).join("; ")} ${limit}`, failed[0].hint);
|
|
28188
|
+
}
|
|
28189
|
+
function unknownUseCase(run, name) {
|
|
28190
|
+
const known = run.diagram.useCases.map((u) => `"${u.name}"`);
|
|
28191
|
+
return { ...fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block'), missing: `use case ${name}` };
|
|
28192
|
+
}
|
|
28193
|
+
function saturatedOn(run, ids, value, maxMs) {
|
|
28194
|
+
const n = [...new Set(ids)].map((id) => run.nodes.get(id)).find((x) => !!x?.saturated);
|
|
28195
|
+
if (!n) return void 0;
|
|
28196
|
+
const v = value(n);
|
|
28197
|
+
return { ...fail(`${v} (limit ${formatMs(maxMs)})`, saturationHint(run, n)), value: v };
|
|
28198
|
+
}
|
|
28199
|
+
function latency(run, u, q, maxMs) {
|
|
28200
|
+
const result = run.useCases.get(u.id);
|
|
28201
|
+
const key = percentileName(q);
|
|
28202
|
+
const shares = run.shares.get(u.id) ?? [];
|
|
28203
|
+
const touched = u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []);
|
|
28204
|
+
const saturated = saturatedOn(run, touched, (n) => `${key} of ${u.name}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
28205
|
+
if (saturated) return saturated;
|
|
28206
|
+
const ms = result.percentiles[key];
|
|
28207
|
+
const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
|
|
28208
|
+
const message = `${value} (limit ${formatMs(maxMs)})`;
|
|
28209
|
+
if (ms < maxMs) return { ...pass(message), value };
|
|
28210
|
+
const index = Math.max(0, result.scenarios.findIndex((s) => s.percentiles[key] === ms));
|
|
28211
|
+
const lead = u.scenarios.length > 1 ? `The "${u.scenarios[index].name}" path (${formatPercent(result.scenarios[index].share)} of traffic) sets ${key}. ` : "";
|
|
28212
|
+
return { ...fail(message, lead + latencyHint(run, u, u.scenarios[index])), value };
|
|
28213
|
+
}
|
|
28214
|
+
function scenarioLatency(run, useCase, scenario, q, maxMs) {
|
|
28215
|
+
const sc = scope(run, useCase, scenario);
|
|
28216
|
+
if (isCheck(sc)) return sc;
|
|
28217
|
+
const s = sc.scenarios[0];
|
|
28218
|
+
const key = percentileName(q);
|
|
28219
|
+
const what = `${key} of "${sc.u.name}" scenario "${s.name}"`;
|
|
28220
|
+
const saturated = saturatedOn(run, s.steps.map((step) => step.toServiceId), (n) => `${what}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
28221
|
+
if (saturated) return saturated;
|
|
28222
|
+
const result = run.useCases.get(sc.u.id)?.scenarios[sc.u.scenarios.indexOf(s)];
|
|
28223
|
+
const ms = result?.percentiles[key] ?? 0;
|
|
28224
|
+
const message = `${what} is ${formatMs(ms)} (limit ${formatMs(maxMs)})`;
|
|
28225
|
+
return ms < maxMs ? pass(message) : fail(message, latencyHint(run, sc.u, s));
|
|
28226
|
+
}
|
|
28227
|
+
function saturationHint(run, n) {
|
|
28228
|
+
const label = nodeLabel(run.diagram, n.id);
|
|
28229
|
+
if (writeBound(n)) {
|
|
28230
|
+
const perShard = n.writeCapacityRps / n.shards;
|
|
28231
|
+
const shards = Math.ceil(n.writeLoadRps / (perShard * HOT));
|
|
28232
|
+
return `Writes to ${label} need ${formatRps(n.writeLoadRps)} and each primary takes ${formatRps(perShard)}; read replicas do not add write capacity. Add shards (capacity { ${n.id} shards ${shards} } keeps writes under ${formatPercent(HOT)}), move the data to a partitioned store (DynamoDB, Cassandra), or batch writes through a queue`;
|
|
28233
|
+
}
|
|
28234
|
+
const rho = n.writeScaling === "shards" ? n.readUtilization : n.utilization;
|
|
28235
|
+
const replicas = Math.ceil(rho * n.replicas / HOT);
|
|
28236
|
+
return `Add replicas to ${label}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
|
|
28237
|
+
}
|
|
28238
|
+
function latencyHint(run, u, scenario) {
|
|
28239
|
+
const hops = criticalPath(u, scenario).flat();
|
|
28240
|
+
const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run.nodes.get(h.target)?.latencyMs ?? 0;
|
|
28241
|
+
const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
|
|
28242
|
+
if (!slowest) return "Shorten the synchronous path of the request.";
|
|
28243
|
+
const label = nodeLabel(run.diagram, slowest.target);
|
|
28244
|
+
if (slowest.failed) return `It waits ${formatMs(DEFAULT_TIMEOUT_MS)} for the failed call to ${label}; give that path a smaller share of traffic or avoid the call.`;
|
|
28245
|
+
const sized = hops.find((h) => (h.step.sizeBytes ?? 0) > 0);
|
|
28246
|
+
const node = run.nodes.get(slowest.target);
|
|
28247
|
+
if (node && node.utilization > HOT) return `${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
|
|
28248
|
+
const payload = sized ? ` Payloads add transfer time too (${stepText(sized.step)}): send fewer bytes or let the client fetch them from a CDN or storage directly.` : "";
|
|
28249
|
+
return `Its slowest hop is ${label} at ${formatMs(cost2(slowest))}: put a cache in front of it, run independent calls in par, or move work off the request path with ->>.${payload}`;
|
|
28250
|
+
}
|
|
28251
|
+
function availability(run, u, minPercent) {
|
|
28252
|
+
const result = run.useCases.get(u.id);
|
|
28253
|
+
const value = `availability of ${u.name} is ${formatAvailability(result.availability)}`;
|
|
28254
|
+
const message = `${value} (limit ${minPercent}%)`;
|
|
28255
|
+
if (result.availability * 100 >= minPercent - 1e-9) return { ...pass(message), value };
|
|
28256
|
+
const shares = run.shares.get(u.id) ?? [];
|
|
28257
|
+
const main = u.scenarios.length ? pathNodes(u, u.scenarios[mainScenarioIndex(shares)]) : [];
|
|
28258
|
+
const weakest = main.filter((id) => fallbacksFor(u, id).length === 0).map((id) => run.nodes.get(id)).filter((n) => !!n && n.availability < 1).sort((a, b) => a.availability - b.availability)[0];
|
|
28259
|
+
const hint = weakest ? `The weakest link is ${nodeLabel(run.diagram, weakest.id)} at ${formatAvailability(weakest.availability)}: add a replica (x${weakest.replicas + 1}) or a fallback scenario that calls it with -x and still completes` : "Add replicas to the nodes on the main path, or fallback scenarios that complete without them";
|
|
28260
|
+
return { ...fail(message, hint), value };
|
|
28261
|
+
}
|
|
28262
|
+
function writeGap(run, u, s, accept) {
|
|
28263
|
+
const { requests, beforeResponse } = requestOrder(u, s);
|
|
28264
|
+
const hits = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
|
|
28265
|
+
const writes = hits.filter(({ m }) => run.isWrite(m.step));
|
|
28266
|
+
const early = writes.find(({ m, i }) => m.async && i < beforeResponse);
|
|
28267
|
+
if (early) return { kind: "async", step: early.m.step };
|
|
28268
|
+
const late = writes.find(({ i }) => i >= beforeResponse);
|
|
28269
|
+
if (late) return { kind: "after", step: late.m.step };
|
|
28270
|
+
const read = hits.find(({ m, i }) => i < beforeResponse && !m.async);
|
|
28271
|
+
if (read) return { kind: "read", step: read.m.step };
|
|
28272
|
+
return { kind: "none" };
|
|
28273
|
+
}
|
|
28274
|
+
function writesCheck(run, u, scenarios, accept, what) {
|
|
28275
|
+
const success = scenarios.filter((s) => s.outcome === "success");
|
|
28276
|
+
if (success.length === 0) return fail(`"${u.name}" has no success scenario to check`, "Add a scenario whose entry request is answered with a 2xx");
|
|
28277
|
+
const gaps = success.filter((s) => !writesBeforeResponse(u, s, accept, run.isWrite));
|
|
28278
|
+
if (gaps.length === 0) {
|
|
28279
|
+
const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
|
|
28280
|
+
return pass(`${label} writes to ${what} before responding`);
|
|
28281
|
+
}
|
|
28282
|
+
const details = gaps.map((s) => ({ s, gap: writeGap(run, u, s, accept) }));
|
|
28283
|
+
const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
|
|
28284
|
+
const text = (s, gap) => {
|
|
28285
|
+
switch (gap.kind) {
|
|
28286
|
+
case "async":
|
|
28287
|
+
return `${where(s)} writes to ${what} only asynchronously: ${stepText(gap.step)}`;
|
|
28288
|
+
case "after":
|
|
28289
|
+
return `${where(s)} writes to ${what} only after responding: ${stepText(gap.step)}`;
|
|
28290
|
+
case "read":
|
|
28291
|
+
return `${where(s)} only reads from ${what} before responding: ${stepText(gap.step)} is a read`;
|
|
28292
|
+
case "none":
|
|
28293
|
+
return `${where(s)} never writes to ${what} before responding`;
|
|
28294
|
+
}
|
|
28295
|
+
};
|
|
28296
|
+
const message = details.map(({ s, gap }) => text(s, gap)).join("; ");
|
|
28297
|
+
const hints = {
|
|
28298
|
+
async: "Make the write a synchronous request (->) so it is acknowledged before the response",
|
|
28299
|
+
after: "Move the write before the step that answers the entry request",
|
|
28300
|
+
read: "Only writes count: start the label with a write verb (INSERT, UPDATE, PUT, SET, \u2026) or use POST, PUT, PATCH or DELETE if the step stores data",
|
|
28301
|
+
none: `Add a synchronous write (->) to ${what} before the entry request is answered`
|
|
28302
|
+
};
|
|
28303
|
+
return fail(message, hints[details[0].gap.kind]);
|
|
28304
|
+
}
|
|
28305
|
+
function durable(run, name) {
|
|
28306
|
+
const u = findUseCase(run.diagram, name);
|
|
28307
|
+
if (!u) return unknownUseCase(run, name);
|
|
28308
|
+
return writesCheck(run, u, u.scenarios, (id) => !!run.nodes.get(id)?.durable, "a durable store");
|
|
28309
|
+
}
|
|
28310
|
+
function survive(run, target) {
|
|
28311
|
+
const all = components(run.diagram);
|
|
28312
|
+
const selected = target === "any" ? all.filter((n) => DEPLOYED(run.nodes.get(n.id).kind)) : selectNodes(run.diagram, target);
|
|
28313
|
+
const what = target === "any" ? "any one node" : selectorText(target);
|
|
28314
|
+
if (selected.length === 0) {
|
|
28315
|
+
return target === "any" ? pass("No nodes to lose") : fail(`No node matches ${what}`, "Use a node id, [Tech] or any <kind>");
|
|
28316
|
+
}
|
|
28317
|
+
const failures = [];
|
|
28318
|
+
for (const node of selected) {
|
|
28319
|
+
const n = run.nodes.get(node.id);
|
|
28320
|
+
const label = nodeLabel(run.diagram, node.id);
|
|
28321
|
+
const replicas = replicasOf(node);
|
|
28322
|
+
if (replicas >= 2) {
|
|
28323
|
+
const left = usageOf(run.profiles.get(node.id), replicas - 1, n.readLoadRps, n.writeLoadRps);
|
|
28324
|
+
if (left.saturated) {
|
|
28325
|
+
failures.push(
|
|
28326
|
+
fail(
|
|
28327
|
+
`Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left.capacityRps)} for ${formatRps(n.loadRps)} (${formatPercent(left.utilization)})`,
|
|
28328
|
+
writeBound(left) ? saturationHint(run, { ...n, ...left }) : `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
|
|
28329
|
+
)
|
|
28330
|
+
);
|
|
28331
|
+
}
|
|
28332
|
+
continue;
|
|
28333
|
+
}
|
|
28334
|
+
for (const u of run.diagram.useCases) {
|
|
28335
|
+
if (needs(u, node.id) && fallbacksFor(u, node.id).length === 0) {
|
|
28336
|
+
failures.push(
|
|
28337
|
+
fail(
|
|
28338
|
+
`Losing ${label} breaks "${u.name}"`,
|
|
28339
|
+
`Add a replica (x2 on ${node.id}) or a fallback scenario in "${u.name}" that calls ${node.id} with -x and still completes`
|
|
28340
|
+
)
|
|
28341
|
+
);
|
|
28342
|
+
}
|
|
28343
|
+
}
|
|
28344
|
+
}
|
|
28345
|
+
if (failures.length === 0) {
|
|
28346
|
+
const count = selected.length === 1 ? selected[0].id : `any of ${selected.length} nodes`;
|
|
28347
|
+
return pass(`Every use case keeps working after losing ${count}`);
|
|
28348
|
+
}
|
|
28349
|
+
const more = failures.length > 1 ? ` (and ${failures.length - 1} more)` : "";
|
|
28350
|
+
return fail(`${failures[0].message}${more}`, failures[0].hint);
|
|
28351
|
+
}
|
|
28352
|
+
function cost(run, max) {
|
|
28353
|
+
const { totalCostUsd: total, totalEgressUsd: egress } = run.analysis;
|
|
28354
|
+
const ofWhich = egress > 0 ? `, ${formatUsd(egress)} of it egress` : "";
|
|
28355
|
+
const message = `Total cost is ${formatUsd(total)}${ofWhich} (limit ${formatUsd(max)})`;
|
|
28356
|
+
if (total <= max) return pass(message);
|
|
28357
|
+
const usd = (x) => formatUsd(x).replace("/month", "");
|
|
28358
|
+
const top = [...run.analysis.nodes].filter((n) => n.costUsd > 0).sort((a, b) => b.costUsd - a.costUsd).slice(0, 3).map((n) => {
|
|
28359
|
+
const notes = [n.replicas > 1 ? `${n.replicas} replicas` : "", n.shards > 1 ? `${n.shards} shards` : "", n.egressUsd > 0 ? `${usd(n.egressUsd)} of it egress` : ""].filter(Boolean);
|
|
28360
|
+
return `${n.id} ${formatUsd(n.costUsd)}${notes.length ? ` (${notes.join(", ")})` : ""}`;
|
|
28361
|
+
});
|
|
28362
|
+
const egressHint = egress > total / 2 ? " Most of it is egress: serve repeated downloads from a CDN ($0.02/GB) instead of storage ($0.09/GB), or send fewer bytes." : "";
|
|
28363
|
+
return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.${egressHint}`);
|
|
28364
|
+
}
|
|
28365
|
+
function flowTest(run, test) {
|
|
28366
|
+
const checks = test.assertions.map((a) => ({ ...assertion(run, a), loc: a.loc }));
|
|
28367
|
+
const assertions = checks.map(({ passed, message, hint, loc }) => hint === void 0 ? { passed, message, loc } : { passed, message, hint, loc });
|
|
28368
|
+
if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
|
|
28369
|
+
const failed = checks.filter((a) => !a.passed);
|
|
28370
|
+
if (failed.length === 0) {
|
|
28371
|
+
return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
|
|
28372
|
+
}
|
|
28373
|
+
const seen = /* @__PURE__ */ new Set();
|
|
28374
|
+
const reported = failed.filter((a) => {
|
|
28375
|
+
const key = a.missing ?? a.message;
|
|
28376
|
+
if (seen.has(key)) return false;
|
|
28377
|
+
seen.add(key);
|
|
28378
|
+
return true;
|
|
28379
|
+
});
|
|
28380
|
+
return { passed: false, message: reported.map((a) => a.message).join("; "), hint: reported[0].hint, assertions };
|
|
28381
|
+
}
|
|
28382
|
+
var quote = (s) => `"${s}"`;
|
|
28383
|
+
function scope(run, useCase, scenario) {
|
|
28384
|
+
const u = findUseCase(run.diagram, useCase);
|
|
28385
|
+
if (!u) return unknownUseCase(run, useCase);
|
|
28386
|
+
if (scenario === void 0) return { u, scenarios: u.scenarios, label: quote(u.name) };
|
|
28387
|
+
const s = findScenario2(u, scenario);
|
|
28388
|
+
if (!s) return missingScenario(u, scenario);
|
|
28389
|
+
return { u, scenarios: [s], label: `${quote(u.name)} scenario ${quote(s.name)}` };
|
|
28390
|
+
}
|
|
28391
|
+
function missingScenario(u, scenario) {
|
|
28392
|
+
const names = u.scenarios.map((s) => quote(s.name)).join(", ");
|
|
28393
|
+
return {
|
|
28394
|
+
...fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`),
|
|
28395
|
+
missing: `scenario ${u.name} \u203A ${scenario}`
|
|
28396
|
+
};
|
|
28397
|
+
}
|
|
28398
|
+
var isCheck = (x) => "passed" in x;
|
|
28399
|
+
var idsOf = (run, selector) => new Set(selectNodes(run.diagram, selector).map((n) => n.id));
|
|
28400
|
+
function assertion(run, a) {
|
|
28401
|
+
switch (a.kind) {
|
|
28402
|
+
case "calls":
|
|
28403
|
+
return calls(run, a);
|
|
28404
|
+
case "before":
|
|
28405
|
+
return before(run, a);
|
|
28406
|
+
case "writesBeforeResponding": {
|
|
28407
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28408
|
+
if (isCheck(sc)) return sc;
|
|
28409
|
+
const ids = idsOf(run, a.target);
|
|
28410
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
28411
|
+
return writesCheck(run, sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
28412
|
+
}
|
|
28413
|
+
case "responds":
|
|
28414
|
+
return responds(run, a);
|
|
28415
|
+
case "hasScenario": {
|
|
28416
|
+
const u = findUseCase(run.diagram, a.useCase);
|
|
28417
|
+
if (!u) return unknownUseCase(run, a.useCase);
|
|
28418
|
+
const s = findScenario2(u, a.scenario);
|
|
28419
|
+
return s ? pass(`${quote(u.name)} has scenario ${quote(s.name)}`) : missingScenario(u, a.scenario);
|
|
28420
|
+
}
|
|
28421
|
+
case "handlesFailure": {
|
|
28422
|
+
const u = findUseCase(run.diagram, a.useCase);
|
|
28423
|
+
if (!u) return unknownUseCase(run, a.useCase);
|
|
28424
|
+
const ids = idsOf(run, a.target);
|
|
28425
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
28426
|
+
const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
|
|
28427
|
+
const what = selectorText(a.target);
|
|
28428
|
+
return handled ? pass(`${quote(u.name)} handles a failed call to ${what} in ${quote(handled.name)}`) : fail(
|
|
28429
|
+
`No success scenario of ${quote(u.name)} has a failed call to ${what}`,
|
|
28430
|
+
`Add alt "${what} down" { \u2026 -x ${[...ids][0]} \u2026 } that still answers the entry request with a 2xx`
|
|
28431
|
+
);
|
|
28432
|
+
}
|
|
28433
|
+
case "noPath":
|
|
28434
|
+
return noPath(run, a.from, a.to);
|
|
28435
|
+
case "replicas": {
|
|
28436
|
+
const nodes = selectNodes(run.diagram, a.target);
|
|
28437
|
+
if (nodes.length === 0) return noMatch(a.target);
|
|
28438
|
+
const short = nodes.filter((n) => replicasOf(n) < a.min);
|
|
28439
|
+
const has = (n) => `${n.id} has ${replicasOf(n)} replica${replicasOf(n) === 1 ? "" : "s"}`;
|
|
28440
|
+
if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
|
|
28441
|
+
return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
|
|
28442
|
+
}
|
|
28443
|
+
case "neverWaits":
|
|
28444
|
+
return neverWaits(run, a);
|
|
28445
|
+
case "after":
|
|
28446
|
+
return after(run, a);
|
|
28447
|
+
case "senderCalls":
|
|
28448
|
+
return senderCalls(run, a);
|
|
28449
|
+
case "startsAt":
|
|
28450
|
+
return startsAt(run, a);
|
|
28451
|
+
}
|
|
28452
|
+
}
|
|
28453
|
+
function noMatch(selector) {
|
|
28454
|
+
return fail(`No node matches ${selectorText(selector)}`, "Use a node id, [Tech] or any <kind> (any cache, any database, \u2026)");
|
|
28455
|
+
}
|
|
28456
|
+
function calls(run, a) {
|
|
28457
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28458
|
+
if (isCheck(sc)) return sc;
|
|
28459
|
+
const what = selectorText(a.target);
|
|
28460
|
+
const ids = idsOf(run, a.target);
|
|
28461
|
+
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId));
|
|
28462
|
+
const calling = sc.scenarios.filter((s) => called(s).length > 0);
|
|
28463
|
+
const names = (list) => list.map((s) => quote(s.name)).join(", ");
|
|
28464
|
+
const multi = sc.scenarios.length > 1;
|
|
28465
|
+
if (a.quantifier === "never") {
|
|
28466
|
+
if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
|
|
28467
|
+
const first = calling[0];
|
|
28468
|
+
const step = called(first)[0];
|
|
28469
|
+
return fail(
|
|
28470
|
+
`${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run.diagram, step.toServiceId)}: ${stepText(step)}`,
|
|
28471
|
+
`Take the call to ${step.toServiceId} out of ${quote(first.name)}, e.g. serve it from a cache`
|
|
28472
|
+
);
|
|
28473
|
+
}
|
|
28474
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
28475
|
+
if (a.quantifier === "some") {
|
|
28476
|
+
if (calling.length) return pass(`${sc.label} calls ${what}${multi ? ` in ${names(calling)}` : ""}`);
|
|
28477
|
+
return fail(`${sc.label} never calls ${what}`, `Add a step to ${what}`);
|
|
28478
|
+
}
|
|
28479
|
+
const missing = sc.scenarios.filter((s) => !calling.includes(s));
|
|
28480
|
+
if (missing.length === 0 && sc.scenarios.length) return pass(`Every scenario of ${sc.label} calls ${what}`);
|
|
28481
|
+
return fail(`${sc.label} does not call ${what} in ${names(missing) || "any scenario"}`, `Add a step to ${what} in ${names(missing) || "a scenario"}`);
|
|
28482
|
+
}
|
|
28483
|
+
function before(run, a) {
|
|
28484
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28485
|
+
if (isCheck(sc)) return sc;
|
|
28486
|
+
const first = idsOf(run, a.first);
|
|
28487
|
+
const then = idsOf(run, a.then);
|
|
28488
|
+
const [x, y] = [selectorText(a.first), selectorText(a.then)];
|
|
28489
|
+
if (first.size === 0) return noMatch(a.first);
|
|
28490
|
+
if (then.size === 0) return noMatch(a.then);
|
|
28491
|
+
let checked = 0;
|
|
28492
|
+
for (const s of sc.scenarios) {
|
|
28493
|
+
const order = requestOrder(sc.u, s).requests;
|
|
28494
|
+
const iy = order.findIndex((m) => then.has(m.to));
|
|
28495
|
+
if (iy < 0) continue;
|
|
28496
|
+
checked++;
|
|
28497
|
+
const ix = order.findIndex((m) => first.has(m.to));
|
|
28498
|
+
if (ix < 0 || ix > iy) {
|
|
28499
|
+
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
28500
|
+
return fail(
|
|
28501
|
+
ix < 0 ? `${where}, ${order[iy].to} is called (${stepText(order[iy].step)}) but ${x} never is` : `${where}, ${order[iy].to} is called (${stepText(order[iy].step)}) before ${order[ix].to} (${stepText(order[ix].step)})`,
|
|
28502
|
+
`Call ${x} before the first call to ${y}`
|
|
28503
|
+
);
|
|
28504
|
+
}
|
|
28505
|
+
}
|
|
28506
|
+
if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
|
|
28507
|
+
return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
|
|
28508
|
+
}
|
|
28509
|
+
function neverWaits(run, a) {
|
|
28510
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28511
|
+
if (isCheck(sc)) return sc;
|
|
28512
|
+
const ids = idsOf(run, a.target);
|
|
28513
|
+
const what = selectorText(a.target);
|
|
28514
|
+
for (const s of sc.scenarios) {
|
|
28515
|
+
const hop = criticalPath(sc.u, s).flat().find((h) => !h.async && ids.has(h.target));
|
|
28516
|
+
if (hop) {
|
|
28517
|
+
const where = sc.scenarios.length > 1 ? quote(s.name) : sc.label;
|
|
28518
|
+
return fail(
|
|
28519
|
+
`${where} waits for ${nodeLabel(run.diagram, hop.target)} before responding: ${stepText(hop.step)}${hop.failed ? " times out on the request path" : " is synchronous"}`,
|
|
28520
|
+
`Send it with ->> (through a queue if it must not be lost), or move it after the line that answers the entry request`
|
|
28521
|
+
);
|
|
28522
|
+
}
|
|
28523
|
+
}
|
|
28524
|
+
const calledAtAll = sc.scenarios.some((s) => s.steps.some((step) => ids.has(step.toServiceId)));
|
|
28525
|
+
return pass(`${sc.label} never waits for ${what}${calledAtAll ? "" : ` (it never calls ${what})`}`);
|
|
28526
|
+
}
|
|
28527
|
+
function after(run, a) {
|
|
28528
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28529
|
+
if (isCheck(sc)) return sc;
|
|
28530
|
+
const targets = idsOf(run, a.target);
|
|
28531
|
+
const firsts = idsOf(run, a.after);
|
|
28532
|
+
const [y, x] = [selectorText(a.target), selectorText(a.after)];
|
|
28533
|
+
if (targets.size === 0) return noMatch(a.target);
|
|
28534
|
+
if (firsts.size === 0) return noMatch(a.after);
|
|
28535
|
+
let checked = 0;
|
|
28536
|
+
for (const s of sc.scenarios) {
|
|
28537
|
+
const order = requestOrder(sc.u, s).requests;
|
|
28538
|
+
const ix = order.findIndex((m) => firsts.has(m.to));
|
|
28539
|
+
const iy = order.map((m) => targets.has(m.to)).lastIndexOf(true);
|
|
28540
|
+
if (ix < 0 || iy < 0) continue;
|
|
28541
|
+
checked++;
|
|
28542
|
+
if (iy < ix) {
|
|
28543
|
+
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
28544
|
+
return fail(
|
|
28545
|
+
`${where}, the last call to ${y} (${stepText(order[iy].step)}) comes before the first call to ${x} (${stepText(order[ix].step)})`,
|
|
28546
|
+
`Call ${y} after ${x}: move the step to ${order[iy].to} below the call to ${order[ix].to}`
|
|
28547
|
+
);
|
|
28548
|
+
}
|
|
28549
|
+
}
|
|
28550
|
+
if (checked === 0) {
|
|
28551
|
+
const callsY = sc.scenarios.some((s) => s.steps.some((step) => targets.has(step.toServiceId)));
|
|
28552
|
+
const callsX = sc.scenarios.some((s) => s.steps.some((step) => firsts.has(step.toServiceId)));
|
|
28553
|
+
const why = !callsY && !callsX ? `calls neither ${x} nor ${y}` : !callsY ? `never calls ${y}` : !callsX ? `never calls ${x}` : `never calls ${x} and ${y} in the same scenario`;
|
|
28554
|
+
return fail(`${sc.label} ${why}`, `Add a step to ${y} after the call to ${x}`);
|
|
28555
|
+
}
|
|
28556
|
+
return pass(`${sc.label} calls ${y} after ${x}${checked > 1 ? ` in all ${checked} scenarios that call both` : ""}`);
|
|
28557
|
+
}
|
|
28558
|
+
function senderCalls(run, a) {
|
|
28559
|
+
let useCases = run.diagram.useCases;
|
|
28560
|
+
if (a.useCase !== void 0) {
|
|
28561
|
+
const u = findUseCase(run.diagram, a.useCase);
|
|
28562
|
+
if (!u) return unknownUseCase(run, a.useCase);
|
|
28563
|
+
useCases = [u];
|
|
28564
|
+
}
|
|
28565
|
+
const from = idsOf(run, a.from);
|
|
28566
|
+
const to = idsOf(run, a.to);
|
|
28567
|
+
const [x, y] = [selectorText(a.from), selectorText(a.to)];
|
|
28568
|
+
const within = a.useCase !== void 0 ? ` in ${quote(useCases[0].name)}` : "";
|
|
28569
|
+
for (const u of useCases) {
|
|
28570
|
+
for (const s of u.scenarios) {
|
|
28571
|
+
const step = s.steps.find((st) => from.has(st.fromServiceId) && to.has(st.toServiceId));
|
|
28572
|
+
if (!step) continue;
|
|
28573
|
+
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
28574
|
+
if (a.quantifier === "some") return pass(`${x} calls ${y}${within}: ${stepText(step)} in ${where}`);
|
|
28575
|
+
return fail(
|
|
28576
|
+
`${x} calls ${y}${within}: ${stepText(step)} in ${where}`,
|
|
28577
|
+
`Let another node make this call (e.g. the client uploads to storage with a presigned URL), or remove the step`
|
|
28578
|
+
);
|
|
28579
|
+
}
|
|
28580
|
+
}
|
|
28581
|
+
if (a.quantifier === "never") return pass(`${x} never calls ${y}${within}`);
|
|
28582
|
+
if (from.size === 0) return noMatch(a.from);
|
|
28583
|
+
if (to.size === 0) return noMatch(a.to);
|
|
28584
|
+
return fail(`No step is sent by ${x} to ${y}${within}`, `Add a step ${[...from][0]} -> ${[...to][0]}${within}`);
|
|
28585
|
+
}
|
|
28586
|
+
function startsAt(run, a) {
|
|
28587
|
+
const u = findUseCase(run.diagram, a.useCase);
|
|
28588
|
+
if (!u) return unknownUseCase(run, a.useCase);
|
|
28589
|
+
const ids = idsOf(run, a.target);
|
|
28590
|
+
const what = selectorText(a.target);
|
|
28591
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
28592
|
+
const entries = u.scenarios.map((s) => s.steps[0]).filter((s) => !!s);
|
|
28593
|
+
if (entries.length === 0) return fail(`${quote(u.name)} has no steps`, `Start "${u.name}" with a step sent by ${what}`);
|
|
28594
|
+
const wrong = entries.find((s) => !ids.has(s.fromServiceId));
|
|
28595
|
+
if (wrong) {
|
|
28596
|
+
return fail(
|
|
28597
|
+
`${quote(u.name)} starts at ${nodeLabel(run.diagram, wrong.fromServiceId)}, not ${what}: ${stepText(wrong)}`,
|
|
28598
|
+
`Make the first step of "${u.name}" a step sent by ${what}`
|
|
28599
|
+
);
|
|
28600
|
+
}
|
|
28601
|
+
return pass(`${quote(u.name)} starts at ${nodeLabel(run.diagram, entries[0].fromServiceId)}`);
|
|
28602
|
+
}
|
|
28603
|
+
function statusMatches(status, wanted) {
|
|
28604
|
+
if (status === void 0) return false;
|
|
28605
|
+
const cls = /^([1-5])xx$/i.exec(wanted);
|
|
28606
|
+
return cls ? Math.floor(status / 100) === Number(cls[1]) : String(status) === wanted;
|
|
28607
|
+
}
|
|
28608
|
+
function responds(run, a) {
|
|
28609
|
+
const sc = scope(run, a.useCase, a.scenario);
|
|
28610
|
+
if (isCheck(sc)) return sc;
|
|
28611
|
+
const hit = sc.scenarios.find((s) => statusMatches(s.steps[0]?.statusCode, a.status));
|
|
28612
|
+
if (hit) return pass(`${sc.label} responds ${hit.steps[0].statusCode}${sc.scenarios.length > 1 ? ` in ${quote(hit.name)}` : ""}`);
|
|
28613
|
+
const seen = [...new Set(sc.scenarios.map((s) => s.steps[0]?.failed ? "a failed call" : s.steps[0]?.statusCode ?? "no status"))];
|
|
28614
|
+
return fail(`${sc.label} never responds ${a.status}; it answers ${seen.join(", ") || "nothing"}`, `Add a scenario (alt) whose entry request is answered with ${a.status}`);
|
|
28615
|
+
}
|
|
28616
|
+
function noPath(run, from, to) {
|
|
28617
|
+
const sources = idsOf(run, from);
|
|
28618
|
+
const targets = idsOf(run, to);
|
|
28619
|
+
const [x, y] = [selectorText(from), selectorText(to)];
|
|
28620
|
+
const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
|
|
28621
|
+
const edge = run.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
|
|
28622
|
+
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${lineOf(edge.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
28623
|
+
for (const u of run.diagram.useCases) {
|
|
28624
|
+
for (const s of u.scenarios) {
|
|
28625
|
+
const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
|
|
28626
|
+
if (step) {
|
|
28627
|
+
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
28628
|
+
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${lineOf(step.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
28629
|
+
}
|
|
28630
|
+
}
|
|
28631
|
+
}
|
|
28632
|
+
return pass(`No connection or step goes directly from ${x} to ${y}`);
|
|
28633
|
+
}
|
|
28634
|
+
|
|
26471
28635
|
// src/proschi.ts
|
|
26472
28636
|
var KEYWORDS2 = [
|
|
26473
28637
|
{ label: "title", detail: "Document title", snippet: 'title "$1"' },
|
|
@@ -26475,15 +28639,116 @@ var KEYWORDS2 = [
|
|
|
26475
28639
|
{ label: "group", detail: 'group id "Name" [Style] { \u2026 }', snippet: 'group ${1:id} "${2:Name}" {\n $0\n}' },
|
|
26476
28640
|
{ label: "usecase", detail: 'usecase "Name" { steps }', snippet: 'usecase "${1:Name}" {\n $0\n}' },
|
|
26477
28641
|
{ label: "par", detail: "Steps that run in parallel", snippet: "par {\n $0\n}" },
|
|
26478
|
-
{ label: "alt", detail: "One scenario of a use case", snippet: 'alt "${1:Scenario}" {\n $0\n}' }
|
|
28642
|
+
{ label: "alt", detail: "One scenario of a use case", snippet: 'alt "${1:Scenario}" {\n $0\n}' },
|
|
28643
|
+
// High-level design sections (top level).
|
|
28644
|
+
{ label: "traffic", detail: 'Requests per use case: "Use case" 100 rps', snippet: 'traffic {\n "${1:Use case}" ${2:100 rps}$0\n}' },
|
|
28645
|
+
{ label: "requirements", detail: "Latency, availability, durability, resilience, cost", snippet: "requirements {\n p99 < ${1:200ms}$0\n}" },
|
|
28646
|
+
{ label: "capacity", detail: "Per-replica overrides: node 1k rps latency 5ms", snippet: "capacity {\n ${1:node} ${2:1k rps}$0\n}" },
|
|
28647
|
+
{ label: "entity", detail: "entity Name in store { fields }", snippet: "entity ${1:Name} in ${2:store} {\n ${3:id} ${4:uuid} key$0\n}" },
|
|
28648
|
+
{ label: "decision", detail: "A design decision with its reasons", snippet: 'decision "${1:Title}" {\n because "${2:reason}"$0\n}' },
|
|
28649
|
+
{ label: "test", detail: 'Flow assertions: "Use case" calls any cache', snippet: 'test "${1:Name}" {\n $0\n}' }
|
|
26479
28650
|
];
|
|
28651
|
+
var ASSERTIONS = [
|
|
28652
|
+
{ label: '"Use case" calls', detail: "Some scenario calls the node", snippet: '"${1:Use case}" calls ${2:node}' },
|
|
28653
|
+
{ label: '"Use case" never calls', detail: "No scenario calls the node", snippet: '"${1:Use case}" never calls ${2:node}' },
|
|
28654
|
+
{ label: '"Use case" every scenario calls', detail: "Every scenario calls the node", snippet: '"${1:Use case}" every scenario calls ${2:node}' },
|
|
28655
|
+
{ label: '"Use case" calls \u2026 before \u2026', detail: "The first call to one node comes before the first call to another", snippet: '"${1:Use case}" calls ${2:node} before ${3:node}' },
|
|
28656
|
+
{ label: '"Use case" calls \u2026 after \u2026', detail: "The last call to one node comes after the first call to another", snippet: '"${1:Use case}" calls ${2:node} after ${3:node}' },
|
|
28657
|
+
{ label: '"Use case" never waits for', detail: "No synchronous call to the node before the response", snippet: '"${1:Use case}" never waits for ${2:node}' },
|
|
28658
|
+
{ label: '"Use case" writes \u2026 before responding', detail: "A write to the node happens before the response", snippet: '"${1:Use case}" writes ${2:node} before responding' },
|
|
28659
|
+
{ label: '"Use case" responds', detail: "Status code or class of the entry response", snippet: '"${1:Use case}" responds ${2:2xx}' },
|
|
28660
|
+
{ label: '"Use case" starts at', detail: "The entry request is sent by the node", snippet: '"${1:Use case}" starts at ${2:node}' },
|
|
28661
|
+
{ label: '"Use case" has scenario', detail: "The use case has a scenario of this name", snippet: '"${1:Use case}" has scenario "${2:Scenario}"' },
|
|
28662
|
+
{ label: '"Use case" handles failure of', detail: "A scenario fails the node and still answers", snippet: '"${1:Use case}" handles failure of ${2:node}' },
|
|
28663
|
+
{ label: "node calls node", detail: "Some step is sent by one node to the other", snippet: "${1:node} calls ${2:node}" },
|
|
28664
|
+
{ label: "node never calls node", detail: "No step is sent by one node to the other", snippet: "${1:node} never calls ${2:node}" },
|
|
28665
|
+
{ label: 'in "Use case" node calls node', detail: "A step of the use case is sent by one node to the other", snippet: 'in "${1:Use case}" ${2:node} calls ${3:node}' },
|
|
28666
|
+
{ label: "no path from \u2026 to \u2026", detail: "No chain of connections links the nodes", snippet: "no path from ${1:node} to ${2:node}" },
|
|
28667
|
+
{ label: "node has replicas >=", detail: "Minimum replica count", snippet: "${1:node} has replicas >= ${2:2}" }
|
|
28668
|
+
];
|
|
28669
|
+
|
|
28670
|
+
// src/imports.ts
|
|
28671
|
+
var import_node_fs3 = require("node:fs");
|
|
28672
|
+
var import_node_path4 = require("node:path");
|
|
28673
|
+
function fileResolver(read = () => void 0, toPath = (p) => p) {
|
|
28674
|
+
return (importPath, fromPath) => {
|
|
28675
|
+
const abs = (0, import_node_path4.resolve)(fromPath ? (0, import_node_path4.dirname)(fromPath) : process.cwd(), importPath);
|
|
28676
|
+
const text = read(abs) ?? ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.statSync)(abs).isFile() ? (0, import_node_fs3.readFileSync)(abs, "utf8") : void 0);
|
|
28677
|
+
return text === void 0 ? void 0 : { path: toPath(abs), source: text };
|
|
28678
|
+
};
|
|
28679
|
+
}
|
|
28680
|
+
function reachableFrom(result, start) {
|
|
28681
|
+
const reached = /* @__PURE__ */ new Set([start]);
|
|
28682
|
+
const queue = [start];
|
|
28683
|
+
while (queue.length) {
|
|
28684
|
+
const file = queue.shift();
|
|
28685
|
+
for (const i of result.imports ?? []) {
|
|
28686
|
+
if (i.loc.file === file && i.resolved !== void 0 && !reached.has(i.resolved)) {
|
|
28687
|
+
reached.add(i.resolved);
|
|
28688
|
+
queue.push(i.resolved);
|
|
28689
|
+
}
|
|
28690
|
+
}
|
|
28691
|
+
}
|
|
28692
|
+
return reached;
|
|
28693
|
+
}
|
|
28694
|
+
function ownDiagnostics(result) {
|
|
28695
|
+
const own = result.diagnostics.filter((d) => d.file === void 0);
|
|
28696
|
+
for (const i of result.imports ?? []) {
|
|
28697
|
+
if (i.loc.file !== void 0 || i.resolved === void 0) continue;
|
|
28698
|
+
const files = reachableFrom(result, i.resolved);
|
|
28699
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error" && d.file !== void 0 && files.has(d.file)).length;
|
|
28700
|
+
if (errors) own.push({ severity: "error", message: `'${(0, import_node_path4.basename)(i.path)}' has ${errors} error${errors === 1 ? "" : "s"}`, ...i.loc });
|
|
28701
|
+
}
|
|
28702
|
+
return own.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
28703
|
+
}
|
|
28704
|
+
function importLinks(result) {
|
|
28705
|
+
return (result.imports ?? []).filter((i) => i.loc.file === void 0 && i.resolved !== void 0).map((i) => {
|
|
28706
|
+
const range = toRange(i.loc);
|
|
28707
|
+
return {
|
|
28708
|
+
range: { start: { ...range.start, character: range.start.character + 1 }, end: { ...range.end, character: Math.max(range.start.character + 1, range.end.character - 1) } },
|
|
28709
|
+
target: i.resolved
|
|
28710
|
+
};
|
|
28711
|
+
});
|
|
28712
|
+
}
|
|
28713
|
+
|
|
28714
|
+
// src/simulation.ts
|
|
28715
|
+
function formatGb(gb) {
|
|
28716
|
+
const n = (x, digits) => Number(x.toFixed(digits)).toLocaleString("en-US");
|
|
28717
|
+
return gb >= 1e3 ? `${n(gb / 1e3, 1)} TB` : `${n(gb, gb < 10 ? 1 : 0)} GB`;
|
|
28718
|
+
}
|
|
28719
|
+
function testDiagnostics(diagram) {
|
|
28720
|
+
const out = [];
|
|
28721
|
+
for (const r of runTests(diagram)) {
|
|
28722
|
+
if (r.passed || !r.loc || r.loc.file !== void 0) continue;
|
|
28723
|
+
const message = `${r.name}: ${r.message}${r.hint ? `. ${r.hint}` : ""}`;
|
|
28724
|
+
out.push({ ...r.loc, severity: "warning", message });
|
|
28725
|
+
for (const a of r.assertions ?? []) {
|
|
28726
|
+
if (a.passed || a.loc.file !== void 0 || a.loc.line === r.loc.line) continue;
|
|
28727
|
+
out.push({ ...a.loc, severity: "warning", message: `${a.message}${a.hint ? `. ${a.hint}` : ""}` });
|
|
28728
|
+
}
|
|
28729
|
+
}
|
|
28730
|
+
return out;
|
|
28731
|
+
}
|
|
28732
|
+
function nodeSimulation(diagram, nodeId) {
|
|
28733
|
+
if ((diagram.traffic ?? []).length === 0) return void 0;
|
|
28734
|
+
const n = analyze(diagram).nodes.find((x) => x.id === nodeId);
|
|
28735
|
+
if (!n || n.kind === "client" || n.kind === "other") return void 0;
|
|
28736
|
+
const state = n.saturated ? " \u2014 **saturated**" : n.utilization > HOT ? " \u2014 hot" : "";
|
|
28737
|
+
const split2 = n.readLoadRps > 0 && n.writeLoadRps > 0 ? [`Reads ${formatRps(n.readLoadRps)} of ${formatRps(n.readCapacityRps)} (${formatPercent(n.readUtilization)}) \xB7 writes ${formatRps(n.writeLoadRps)} of ${formatRps(n.writeCapacityRps)} (${formatPercent(n.writeUtilization)})`] : [];
|
|
28738
|
+
const egress = n.egressGbPerMonth > 0 ? ` (${formatUsd(n.egressUsd).replace("/month", "")} of it egress, ${formatGb(n.egressGbPerMonth)})` : "";
|
|
28739
|
+
return [
|
|
28740
|
+
`Load ${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)} (${formatPercent(n.utilization)} utilised${state})`,
|
|
28741
|
+
...split2,
|
|
28742
|
+
`Latency ${formatMs(n.latencyMs)} per call \xB7 availability ${formatAvailability(n.availability)} \xB7 ${formatUsd(n.costUsd)}${n.replicas > 1 ? ` for ${n.replicas} replicas` : ""}${egress}`
|
|
28743
|
+
].join(" \n");
|
|
28744
|
+
}
|
|
26480
28745
|
|
|
26481
28746
|
// src/analysis.ts
|
|
26482
28747
|
function toRange(loc) {
|
|
26483
28748
|
const start = { line: loc.line - 1, character: loc.col - 1 };
|
|
26484
28749
|
return { start, end: { line: start.line, character: start.character + Math.max(loc.length, 1) } };
|
|
26485
28750
|
}
|
|
26486
|
-
function
|
|
28751
|
+
function analyze2(text, options) {
|
|
26487
28752
|
const result = parse(text, options);
|
|
26488
28753
|
return { diagram: result.diagram, diagnostics: result.diagnostics, lines: text.split("\n"), result };
|
|
26489
28754
|
}
|
|
@@ -26496,9 +28761,9 @@ function wordAt(line, character) {
|
|
|
26496
28761
|
if (start === end || !/[A-Za-z_]/.test(line[start])) return null;
|
|
26497
28762
|
return { word: line.slice(start, end), start, end };
|
|
26498
28763
|
}
|
|
26499
|
-
function inLabel(
|
|
28764
|
+
function inLabel(before2) {
|
|
26500
28765
|
let quoted = false;
|
|
26501
|
-
for (const ch of
|
|
28766
|
+
for (const ch of before2) {
|
|
26502
28767
|
if (ch === '"') quoted = !quoted;
|
|
26503
28768
|
else if (ch === ":" && !quoted) return true;
|
|
26504
28769
|
else if (ch === "#" && !quoted) return false;
|
|
@@ -26530,9 +28795,9 @@ function labelStart(text) {
|
|
|
26530
28795
|
}
|
|
26531
28796
|
function complete(analysis, pos) {
|
|
26532
28797
|
const line = analysis.lines[pos.line] ?? "";
|
|
26533
|
-
const
|
|
28798
|
+
const before2 = line.slice(0, pos.character);
|
|
26534
28799
|
if (insidePayload(analysis.lines, pos.line)) return [];
|
|
26535
|
-
const tech =
|
|
28800
|
+
const tech = before2.match(/\[([^\]]*)$/);
|
|
26536
28801
|
if (tech) {
|
|
26537
28802
|
const closed = line[pos.character] === "]";
|
|
26538
28803
|
const range2 = { start: { line: pos.line, character: pos.character - tech[1].length }, end: pos };
|
|
@@ -26544,11 +28809,11 @@ function complete(analysis, pos) {
|
|
|
26544
28809
|
range: range2
|
|
26545
28810
|
}));
|
|
26546
28811
|
}
|
|
26547
|
-
if (inLabel(
|
|
26548
|
-
const word =
|
|
28812
|
+
if (inLabel(before2)) return [];
|
|
28813
|
+
const word = before2.match(/[A-Za-z_]\w*$/)?.[0] ?? "";
|
|
26549
28814
|
const range = { start: { line: pos.line, character: pos.character - word.length }, end: pos };
|
|
26550
|
-
const atLineStart = /^\s*(\}\s*)?$/.test(
|
|
26551
|
-
if (/^\s*(\}\s*)?alt\s+"[^"]*"\s+$/.test(
|
|
28815
|
+
const atLineStart = /^\s*(\}\s*)?$/.test(before2.slice(0, before2.length - word.length));
|
|
28816
|
+
if (/^\s*(\}\s*)?alt\s+"[^"]*"\s+$/.test(before2.slice(0, before2.length - word.length))) {
|
|
26552
28817
|
return [{ label: "when", kind: "keyword", detail: "Condition of this scenario", snippet: 'when "${1:condition}" {\n $0\n}', range }];
|
|
26553
28818
|
}
|
|
26554
28819
|
const nodes = analysis.diagram.nodes.map((n) => ({
|
|
@@ -26558,7 +28823,11 @@ function complete(analysis, pos) {
|
|
|
26558
28823
|
range
|
|
26559
28824
|
}));
|
|
26560
28825
|
if (!atLineStart) return nodes;
|
|
26561
|
-
|
|
28826
|
+
if (inTestBlock(analysis.lines, pos.line) && !/\}/.test(before2)) {
|
|
28827
|
+
const assertions = ASSERTIONS.map((a) => ({ label: a.label, kind: "keyword", detail: a.detail, snippet: a.snippet, range }));
|
|
28828
|
+
return [...assertions, ...nodes];
|
|
28829
|
+
}
|
|
28830
|
+
const afterBrace = /\}\s*$/.test(before2.slice(0, before2.length - word.length));
|
|
26562
28831
|
const keywords = KEYWORDS2.filter((k) => !afterBrace || k.label === "alt").map((k) => ({
|
|
26563
28832
|
label: k.label,
|
|
26564
28833
|
kind: "keyword",
|
|
@@ -26568,6 +28837,14 @@ function complete(analysis, pos) {
|
|
|
26568
28837
|
}));
|
|
26569
28838
|
return afterBrace ? keywords : [...keywords, ...nodes];
|
|
26570
28839
|
}
|
|
28840
|
+
function inTestBlock(lines, index) {
|
|
28841
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
28842
|
+
const line = lines[i];
|
|
28843
|
+
if (/^\s*\}/.test(line)) return false;
|
|
28844
|
+
if (/\{\s*(?:#.*)?$/.test(line)) return /^\s*test\s+"/.test(line);
|
|
28845
|
+
}
|
|
28846
|
+
return false;
|
|
28847
|
+
}
|
|
26571
28848
|
function describeNode(n) {
|
|
26572
28849
|
return [n.name !== n.id ? n.name : "", n.techStack, n.ownerTeam ? `@${n.ownerTeam}` : ""].filter(Boolean).join(" \xB7 ");
|
|
26573
28850
|
}
|
|
@@ -26576,8 +28853,8 @@ function nodeAt(analysis, pos) {
|
|
|
26576
28853
|
if (insidePayload(analysis.lines, pos.line)) return null;
|
|
26577
28854
|
const word = wordAt(line, pos.character);
|
|
26578
28855
|
if (!word) return null;
|
|
26579
|
-
const
|
|
26580
|
-
if (inLabel(
|
|
28856
|
+
const before2 = line.slice(0, word.start);
|
|
28857
|
+
if (inLabel(before2) || (before2.match(/"/g)?.length ?? 0) % 2 === 1 || /\[[^\]]*$/.test(before2) || /@[\w-]*$/.test(before2)) return null;
|
|
26581
28858
|
const node = analysis.diagram.nodes.find((n) => n.id === word.word);
|
|
26582
28859
|
if (!node) return null;
|
|
26583
28860
|
return { node, range: { start: { line: pos.line, character: word.start }, end: { line: pos.line, character: word.end } } };
|
|
@@ -26621,6 +28898,8 @@ function hover(analysis, pos) {
|
|
|
26621
28898
|
if (n.implicit) lines.push("", "_Not declared; created because it is referenced._");
|
|
26622
28899
|
else if (n.loc.file) lines.push("", `_Declared in ${n.loc.file.split(/[\\/]/).pop()}_`);
|
|
26623
28900
|
if (useCases.length) lines.push("", `Used in: ${useCases.map((u) => `\u201C${u.name}\u201D`).join(", ")}`);
|
|
28901
|
+
const load = nodeSimulation(analysis.diagram, n.id);
|
|
28902
|
+
if (load) lines.push("", load);
|
|
26624
28903
|
return { markdown: lines.join("\n"), range: hit.range };
|
|
26625
28904
|
}
|
|
26626
28905
|
function outline(analysis) {
|
|
@@ -26646,7 +28925,27 @@ function outline(analysis) {
|
|
|
26646
28925
|
children: []
|
|
26647
28926
|
})) : []
|
|
26648
28927
|
}));
|
|
26649
|
-
return [...top, ...useCases];
|
|
28928
|
+
return [...top, ...useCases, ...sections(analysis)];
|
|
28929
|
+
}
|
|
28930
|
+
function sections(analysis) {
|
|
28931
|
+
const { diagram, lines } = analysis;
|
|
28932
|
+
const own = (items) => (items ?? []).filter((i) => !i.loc.file);
|
|
28933
|
+
const item = (kind, name, loc, detail) => ({ name, detail: detail || void 0, kind, range: toRange(loc), children: [] });
|
|
28934
|
+
const sourceOf = (loc) => (lines[loc.line - 1] ?? "").slice(loc.col - 1, loc.col - 1 + loc.length);
|
|
28935
|
+
const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
|
|
28936
|
+
const groups = [
|
|
28937
|
+
["traffic", own(diagram.traffic).map((t) => item("traffic", t.useCase, t.loc, [`${t.rps} rps`, t.mix ? count(t.mix.length, "scenario") : ""].filter(Boolean).join(" \xB7 ")))],
|
|
28938
|
+
["requirements", own(diagram.requirements).map((r) => item("requirement", sourceOf(r.loc), r.loc))],
|
|
28939
|
+
["entities", own(diagram.entities).map((e) => item("entity", e.name, e.loc, [e.store ? `in ${e.store}` : "", count(e.fields.length, "field")].filter(Boolean).join(" \xB7 ")))],
|
|
28940
|
+
["decisions", own(diagram.decisions).map((d) => item("decision", d.title, d.loc, d.because))],
|
|
28941
|
+
["tests", own(diagram.tests).map((t) => item("test", t.name, t.loc, count(t.assertions.length, "assertion")))]
|
|
28942
|
+
];
|
|
28943
|
+
return groups.filter(([, children]) => children.length).map(([name, children]) => ({
|
|
28944
|
+
name,
|
|
28945
|
+
kind: "section",
|
|
28946
|
+
range: { start: children[0].range.start, end: children.reduce((end, c) => c.range.end.line >= end.line ? c.range.end : end, children[0].range.end) },
|
|
28947
|
+
children
|
|
28948
|
+
}));
|
|
26650
28949
|
}
|
|
26651
28950
|
var MISSING_CONNECTION = /^No connection between '\w+' and '\w+' in the architecture; add '(\w+) -> (\w+)'$/;
|
|
26652
28951
|
function quickFix(analysis, message) {
|
|
@@ -26658,65 +28957,21 @@ function quickFix(analysis, message) {
|
|
|
26658
28957
|
if (updated === text) return null;
|
|
26659
28958
|
return { title: `Add connection '${from} -> ${to}'`, edit: diffEdit(text, updated) };
|
|
26660
28959
|
}
|
|
26661
|
-
function diffEdit(
|
|
28960
|
+
function diffEdit(before2, after2) {
|
|
26662
28961
|
let start = 0;
|
|
26663
|
-
while (start <
|
|
28962
|
+
while (start < before2.length && start < after2.length && before2[start] === after2[start]) start++;
|
|
26664
28963
|
let end = 0;
|
|
26665
|
-
while (end <
|
|
26666
|
-
return { range: { start: positionAt(
|
|
28964
|
+
while (end < before2.length - start && end < after2.length - start && before2[before2.length - 1 - end] === after2[after2.length - 1 - end]) end++;
|
|
28965
|
+
return { range: { start: positionAt(before2, start), end: positionAt(before2, before2.length - end) }, newText: after2.slice(start, after2.length - end) };
|
|
26667
28966
|
}
|
|
26668
28967
|
function positionAt(text, offset) {
|
|
26669
|
-
const
|
|
26670
|
-
return { line:
|
|
26671
|
-
}
|
|
26672
|
-
|
|
26673
|
-
// src/imports.ts
|
|
26674
|
-
var import_node_fs3 = require("node:fs");
|
|
26675
|
-
var import_node_path4 = require("node:path");
|
|
26676
|
-
function fileResolver(read = () => void 0, toPath = (p) => p) {
|
|
26677
|
-
return (importPath, fromPath) => {
|
|
26678
|
-
const abs = (0, import_node_path4.resolve)(fromPath ? (0, import_node_path4.dirname)(fromPath) : process.cwd(), importPath);
|
|
26679
|
-
const text = read(abs) ?? ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.statSync)(abs).isFile() ? (0, import_node_fs3.readFileSync)(abs, "utf8") : void 0);
|
|
26680
|
-
return text === void 0 ? void 0 : { path: toPath(abs), source: text };
|
|
26681
|
-
};
|
|
26682
|
-
}
|
|
26683
|
-
function reachableFrom(result, start) {
|
|
26684
|
-
const reached = /* @__PURE__ */ new Set([start]);
|
|
26685
|
-
const queue = [start];
|
|
26686
|
-
while (queue.length) {
|
|
26687
|
-
const file = queue.shift();
|
|
26688
|
-
for (const i of result.imports ?? []) {
|
|
26689
|
-
if (i.loc.file === file && i.resolved !== void 0 && !reached.has(i.resolved)) {
|
|
26690
|
-
reached.add(i.resolved);
|
|
26691
|
-
queue.push(i.resolved);
|
|
26692
|
-
}
|
|
26693
|
-
}
|
|
26694
|
-
}
|
|
26695
|
-
return reached;
|
|
26696
|
-
}
|
|
26697
|
-
function ownDiagnostics(result) {
|
|
26698
|
-
const own = result.diagnostics.filter((d) => d.file === void 0);
|
|
26699
|
-
for (const i of result.imports ?? []) {
|
|
26700
|
-
if (i.loc.file !== void 0 || i.resolved === void 0) continue;
|
|
26701
|
-
const files = reachableFrom(result, i.resolved);
|
|
26702
|
-
const errors = result.diagnostics.filter((d) => d.severity === "error" && d.file !== void 0 && files.has(d.file)).length;
|
|
26703
|
-
if (errors) own.push({ severity: "error", message: `'${(0, import_node_path4.basename)(i.path)}' has ${errors} error${errors === 1 ? "" : "s"}`, ...i.loc });
|
|
26704
|
-
}
|
|
26705
|
-
return own.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
26706
|
-
}
|
|
26707
|
-
function importLinks(result) {
|
|
26708
|
-
return (result.imports ?? []).filter((i) => i.loc.file === void 0 && i.resolved !== void 0).map((i) => {
|
|
26709
|
-
const range = toRange(i.loc);
|
|
26710
|
-
return {
|
|
26711
|
-
range: { start: { ...range.start, character: range.start.character + 1 }, end: { ...range.end, character: Math.max(range.start.character + 1, range.end.character - 1) } },
|
|
26712
|
-
target: i.resolved
|
|
26713
|
-
};
|
|
26714
|
-
});
|
|
28968
|
+
const before2 = text.slice(0, offset).split("\n");
|
|
28969
|
+
return { line: before2.length - 1, character: before2[before2.length - 1].length };
|
|
26715
28970
|
}
|
|
26716
28971
|
|
|
26717
28972
|
// src/server.ts
|
|
26718
28973
|
if (process.argv.includes("--version")) {
|
|
26719
|
-
console.log(true ? "0.
|
|
28974
|
+
console.log(true ? "0.4.0" : "dev");
|
|
26720
28975
|
process.exit(0);
|
|
26721
28976
|
}
|
|
26722
28977
|
var connection = (0, import_node.createConnection)(import_node.ProposedFeatures.all);
|
|
@@ -26731,7 +28986,7 @@ var resolve3 = fileResolver(openText);
|
|
|
26731
28986
|
function analysisOf(doc) {
|
|
26732
28987
|
const cached = cache2.get(doc.uri);
|
|
26733
28988
|
if (cached?.version === doc.version) return cached.analysis;
|
|
26734
|
-
const analysis =
|
|
28989
|
+
const analysis = analyze2(doc.getText(), { path: pathOf(doc.uri), resolve: resolve3 });
|
|
26735
28990
|
const files = new Set((analysis.result.imports ?? []).flatMap((i) => i.resolved ? [i.resolved] : []));
|
|
26736
28991
|
cache2.set(doc.uri, { version: doc.version, analysis, files });
|
|
26737
28992
|
return analysis;
|
|
@@ -26757,7 +29012,7 @@ connection.onInitialize(() => ({
|
|
|
26757
29012
|
documentFormattingProvider: true,
|
|
26758
29013
|
codeActionProvider: { codeActionKinds: [import_node.CodeActionKind.QuickFix] }
|
|
26759
29014
|
},
|
|
26760
|
-
serverInfo: { name: "proschi-language-server", version: true ? "0.
|
|
29015
|
+
serverInfo: { name: "proschi-language-server", version: true ? "0.4.0" : "dev" }
|
|
26761
29016
|
}));
|
|
26762
29017
|
function validate(document) {
|
|
26763
29018
|
const analysis = analysisOf(document);
|
|
@@ -26773,10 +29028,19 @@ function validate(document) {
|
|
|
26773
29028
|
diagnostics: [
|
|
26774
29029
|
...ownDiagnostics(analysis.result).map(toLsp("proschi")),
|
|
26775
29030
|
// Findings on steps in imported files belong to those files.
|
|
26776
|
-
...openApiFindings(document, analysis.diagram).filter((d) => d.file === void 0).map(toLsp("proschi-openapi"))
|
|
29031
|
+
...openApiFindings(document, analysis.diagram).filter((d) => d.file === void 0).map(toLsp("proschi-openapi")),
|
|
29032
|
+
...simulationFindings(analysis.diagram).map(toLsp("proschi-test"))
|
|
26777
29033
|
]
|
|
26778
29034
|
});
|
|
26779
29035
|
}
|
|
29036
|
+
function simulationFindings(diagram) {
|
|
29037
|
+
try {
|
|
29038
|
+
return testDiagnostics(diagram);
|
|
29039
|
+
} catch (e) {
|
|
29040
|
+
connection.console.error(`Simulation failed: ${String(e)}`);
|
|
29041
|
+
return [];
|
|
29042
|
+
}
|
|
29043
|
+
}
|
|
26780
29044
|
function openApiFindings(document, diagram) {
|
|
26781
29045
|
if (!document.uri.startsWith("file:")) return [];
|
|
26782
29046
|
try {
|
|
@@ -26847,7 +29111,18 @@ connection.onReferences((params) => {
|
|
|
26847
29111
|
if (!doc) return [];
|
|
26848
29112
|
return references(analysisOf(doc), params.position).map((range) => ({ uri: params.textDocument.uri, range }));
|
|
26849
29113
|
});
|
|
26850
|
-
var SYMBOL_KIND = {
|
|
29114
|
+
var SYMBOL_KIND = {
|
|
29115
|
+
node: import_node.SymbolKind.Object,
|
|
29116
|
+
group: import_node.SymbolKind.Namespace,
|
|
29117
|
+
usecase: import_node.SymbolKind.Function,
|
|
29118
|
+
scenario: import_node.SymbolKind.Event,
|
|
29119
|
+
section: import_node.SymbolKind.Module,
|
|
29120
|
+
traffic: import_node.SymbolKind.Property,
|
|
29121
|
+
requirement: import_node.SymbolKind.Constant,
|
|
29122
|
+
entity: import_node.SymbolKind.Struct,
|
|
29123
|
+
decision: import_node.SymbolKind.Key,
|
|
29124
|
+
test: import_node.SymbolKind.Method
|
|
29125
|
+
};
|
|
26851
29126
|
function toSymbol(s) {
|
|
26852
29127
|
return { name: s.name, detail: s.detail, kind: SYMBOL_KIND[s.kind], range: s.range, selectionRange: s.range, children: s.children.map(toSymbol) };
|
|
26853
29128
|
}
|