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/cli.cjs
CHANGED
|
@@ -2796,11 +2796,11 @@ var require_validate = __commonJS({
|
|
|
2796
2796
|
jsonPointer = $data;
|
|
2797
2797
|
data = names_1.default.rootData;
|
|
2798
2798
|
} else {
|
|
2799
|
-
const
|
|
2800
|
-
if (!
|
|
2799
|
+
const matches2 = RELATIVE_JSON_POINTER.exec($data);
|
|
2800
|
+
if (!matches2)
|
|
2801
2801
|
throw new Error(`Invalid JSON-pointer: ${$data}`);
|
|
2802
|
-
const up = +
|
|
2803
|
-
jsonPointer =
|
|
2802
|
+
const up = +matches2[1];
|
|
2803
|
+
jsonPointer = matches2[2];
|
|
2804
2804
|
if (jsonPointer === "#") {
|
|
2805
2805
|
if (up >= dataLevel)
|
|
2806
2806
|
throw new Error(errorMsg("property/index", up));
|
|
@@ -3687,11 +3687,11 @@ var require_schemes = __commonJS({
|
|
|
3687
3687
|
urnComponent.error = "URN can not be parsed";
|
|
3688
3688
|
return urnComponent;
|
|
3689
3689
|
}
|
|
3690
|
-
const
|
|
3691
|
-
if (
|
|
3690
|
+
const matches2 = urnComponent.path.match(URN_REG);
|
|
3691
|
+
if (matches2 && matches2[0] === urnComponent.path) {
|
|
3692
3692
|
const scheme = options.scheme || urnComponent.scheme || "urn";
|
|
3693
|
-
urnComponent.nid =
|
|
3694
|
-
urnComponent.nss =
|
|
3693
|
+
urnComponent.nid = matches2[1].toLowerCase();
|
|
3694
|
+
urnComponent.nss = matches2[2];
|
|
3695
3695
|
const urnScheme = `${scheme}:${options.nid || urnComponent.nid}`;
|
|
3696
3696
|
const schemeHandler = getSchemeHandler(urnScheme);
|
|
3697
3697
|
urnComponent.path = void 0;
|
|
@@ -4005,8 +4005,8 @@ var require_fast_uri = __commonJS({
|
|
|
4005
4005
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
4006
4006
|
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
4007
4007
|
var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
|
|
4008
|
-
function getParseError(parsed,
|
|
4009
|
-
if (
|
|
4008
|
+
function getParseError(parsed, matches2) {
|
|
4009
|
+
if (matches2[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
4010
4010
|
return 'URI path must start with "/" when authority is present.';
|
|
4011
4011
|
}
|
|
4012
4012
|
if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
|
|
@@ -4028,9 +4028,9 @@ var require_fast_uri = __commonJS({
|
|
|
4028
4028
|
function isIPLiteral(host) {
|
|
4029
4029
|
return host[0] === "[" && host[host.length - 1] === "]";
|
|
4030
4030
|
}
|
|
4031
|
-
function hasMalformedComponentPercentEncoding(
|
|
4032
|
-
const host =
|
|
4033
|
-
return hasMalformedPercentEncoding(
|
|
4031
|
+
function hasMalformedComponentPercentEncoding(matches2) {
|
|
4032
|
+
const host = matches2[4];
|
|
4033
|
+
return hasMalformedPercentEncoding(matches2[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches2[6]) || hasMalformedPercentEncoding(matches2[7]) || hasMalformedPercentEncoding(matches2[8]);
|
|
4034
4034
|
}
|
|
4035
4035
|
function canonicalizeHost(parsed, options, schemeHandler, isIP) {
|
|
4036
4036
|
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && !isIPLiteral(parsed.host) && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
|
|
@@ -4087,15 +4087,15 @@ var require_fast_uri = __commonJS({
|
|
|
4087
4087
|
}
|
|
4088
4088
|
}
|
|
4089
4089
|
}
|
|
4090
|
-
const
|
|
4091
|
-
if (
|
|
4092
|
-
parsed.scheme =
|
|
4093
|
-
parsed.userinfo =
|
|
4094
|
-
parsed.host =
|
|
4095
|
-
parsed.port = parseInt(
|
|
4096
|
-
parsed.path =
|
|
4097
|
-
parsed.query =
|
|
4098
|
-
parsed.fragment =
|
|
4090
|
+
const matches2 = uri.match(URI_PARSE);
|
|
4091
|
+
if (matches2) {
|
|
4092
|
+
parsed.scheme = matches2[1];
|
|
4093
|
+
parsed.userinfo = matches2[3];
|
|
4094
|
+
parsed.host = matches2[4];
|
|
4095
|
+
parsed.port = parseInt(matches2[5], 10);
|
|
4096
|
+
parsed.path = matches2[6] || "";
|
|
4097
|
+
parsed.query = matches2[7];
|
|
4098
|
+
parsed.fragment = matches2[8];
|
|
4099
4099
|
if (parsed.scheme !== void 0) {
|
|
4100
4100
|
const decodedScheme = unescape(parsed.scheme);
|
|
4101
4101
|
if (VALID_SCHEME.test(decodedScheme)) {
|
|
@@ -4105,14 +4105,14 @@ var require_fast_uri = __commonJS({
|
|
|
4105
4105
|
malformedScheme = true;
|
|
4106
4106
|
}
|
|
4107
4107
|
}
|
|
4108
|
-
malformedPercentEncoding = hasMalformedComponentPercentEncoding(
|
|
4108
|
+
malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches2);
|
|
4109
4109
|
if (malformedPercentEncoding) {
|
|
4110
4110
|
parsed.error = parsed.error || "URI contains malformed percent-encoding.";
|
|
4111
4111
|
}
|
|
4112
4112
|
if (isNaN(parsed.port)) {
|
|
4113
|
-
parsed.port =
|
|
4113
|
+
parsed.port = matches2[5];
|
|
4114
4114
|
}
|
|
4115
|
-
const parseError = getParseError(parsed,
|
|
4115
|
+
const parseError = getParseError(parsed, matches2);
|
|
4116
4116
|
if (parseError !== void 0) {
|
|
4117
4117
|
parsed.error = parsed.error || parseError;
|
|
4118
4118
|
malformedAuthorityOrPort = true;
|
|
@@ -4820,13 +4820,13 @@ var require_core = __commonJS({
|
|
|
4820
4820
|
RULES.all[keyword] = rule;
|
|
4821
4821
|
(_a = definition.implements) === null || _a === void 0 ? void 0 : _a.forEach((kwd) => this.addKeyword(kwd));
|
|
4822
4822
|
}
|
|
4823
|
-
function addBeforeRule(ruleGroup, rule,
|
|
4824
|
-
const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword ===
|
|
4823
|
+
function addBeforeRule(ruleGroup, rule, before2) {
|
|
4824
|
+
const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before2);
|
|
4825
4825
|
if (i >= 0) {
|
|
4826
4826
|
ruleGroup.rules.splice(i, 0, rule);
|
|
4827
4827
|
} else {
|
|
4828
4828
|
ruleGroup.rules.push(rule);
|
|
4829
|
-
this.logger.warn(`rule ${
|
|
4829
|
+
this.logger.warn(`rule ${before2} is not defined`);
|
|
4830
4830
|
}
|
|
4831
4831
|
}
|
|
4832
4832
|
function keywordMetaschema(def) {
|
|
@@ -12353,11 +12353,11 @@ var require_compose_scalar = __commonJS({
|
|
|
12353
12353
|
var require_util_empty_scalar_position = __commonJS({
|
|
12354
12354
|
"node_modules/yaml/dist/compose/util-empty-scalar-position.js"(exports2) {
|
|
12355
12355
|
"use strict";
|
|
12356
|
-
function emptyScalarPosition(offset,
|
|
12357
|
-
if (
|
|
12358
|
-
pos ?? (pos =
|
|
12356
|
+
function emptyScalarPosition(offset, before2, pos) {
|
|
12357
|
+
if (before2) {
|
|
12358
|
+
pos ?? (pos = before2.length);
|
|
12359
12359
|
for (let i = pos - 1; i >= 0; --i) {
|
|
12360
|
-
let st =
|
|
12360
|
+
let st = before2[i];
|
|
12361
12361
|
switch (st.type) {
|
|
12362
12362
|
case "space":
|
|
12363
12363
|
case "comment":
|
|
@@ -12365,10 +12365,10 @@ var require_util_empty_scalar_position = __commonJS({
|
|
|
12365
12365
|
offset -= st.source.length;
|
|
12366
12366
|
continue;
|
|
12367
12367
|
}
|
|
12368
|
-
st =
|
|
12368
|
+
st = before2[++i];
|
|
12369
12369
|
while (st?.type === "space") {
|
|
12370
12370
|
offset += st.source.length;
|
|
12371
|
-
st =
|
|
12371
|
+
st = before2[++i];
|
|
12372
12372
|
}
|
|
12373
12373
|
break;
|
|
12374
12374
|
}
|
|
@@ -12446,10 +12446,10 @@ var require_compose_node = __commonJS({
|
|
|
12446
12446
|
node.srcToken = token;
|
|
12447
12447
|
return node;
|
|
12448
12448
|
}
|
|
12449
|
-
function composeEmptyNode(ctx, offset,
|
|
12449
|
+
function composeEmptyNode(ctx, offset, before2, pos, { spaceBefore, comment, anchor, tag, end }, onError) {
|
|
12450
12450
|
const token = {
|
|
12451
12451
|
type: "scalar",
|
|
12452
|
-
offset: utilEmptyScalarPosition.emptyScalarPosition(offset,
|
|
12452
|
+
offset: utilEmptyScalarPosition.emptyScalarPosition(offset, before2, pos),
|
|
12453
12453
|
indent: -1,
|
|
12454
12454
|
source: ""
|
|
12455
12455
|
};
|
|
@@ -13464,9 +13464,9 @@ var require_lexer = __commonJS({
|
|
|
13464
13464
|
}
|
|
13465
13465
|
}
|
|
13466
13466
|
*parseQuotedScalar() {
|
|
13467
|
-
const
|
|
13468
|
-
let end = this.buffer.indexOf(
|
|
13469
|
-
if (
|
|
13467
|
+
const quote2 = this.charAt(0);
|
|
13468
|
+
let end = this.buffer.indexOf(quote2, this.pos + 1);
|
|
13469
|
+
if (quote2 === "'") {
|
|
13470
13470
|
while (end !== -1 && this.buffer[end + 1] === "'")
|
|
13471
13471
|
end = this.buffer.indexOf("'", end + 2);
|
|
13472
13472
|
} else {
|
|
@@ -14934,8 +14934,8 @@ var OpenApiSpec = class {
|
|
|
14934
14934
|
return requestBody && this.validateContent(requestBody.pointer, body);
|
|
14935
14935
|
}
|
|
14936
14936
|
validateResponse(op, key, body) {
|
|
14937
|
-
const
|
|
14938
|
-
return
|
|
14937
|
+
const response2 = this.resolve(`${op.pointer}/responses/${esc(key)}`);
|
|
14938
|
+
return response2 && this.validateContent(response2.pointer, body);
|
|
14939
14939
|
}
|
|
14940
14940
|
/** Validates against the `application/json` (or `+json`) schema of a request body or response object. */
|
|
14941
14941
|
validateContent(pointer, body) {
|
|
@@ -15090,9 +15090,9 @@ function checkStep(spec, step, warn) {
|
|
|
15090
15090
|
}
|
|
15091
15091
|
if (step.failed) return;
|
|
15092
15092
|
const label = `${op.method} ${op.path}`;
|
|
15093
|
-
const
|
|
15094
|
-
if (
|
|
15095
|
-
const errors = spec.validateRequest(op,
|
|
15093
|
+
const request2 = jsonBody(step.requestFormat, step.requestBody);
|
|
15094
|
+
if (request2 !== void 0) {
|
|
15095
|
+
const errors = spec.validateRequest(op, request2.value);
|
|
15096
15096
|
if (errors?.length) warn(`Request body does not match the schema of ${label}: ${summarize(errors)}`, step.loc);
|
|
15097
15097
|
}
|
|
15098
15098
|
if (step.statusCode === void 0) return;
|
|
@@ -15103,9 +15103,9 @@ function checkStep(spec, step, warn) {
|
|
|
15103
15103
|
warn(`Status ${step.statusCode} is not documented for ${label} (documented: ${documented.join(", ") || "none"})`, responseLoc);
|
|
15104
15104
|
return;
|
|
15105
15105
|
}
|
|
15106
|
-
const
|
|
15107
|
-
if (
|
|
15108
|
-
const errors = spec.validateResponse(op, key,
|
|
15106
|
+
const response2 = jsonBody(step.responseFormat, step.responseBody);
|
|
15107
|
+
if (response2 !== void 0) {
|
|
15108
|
+
const errors = spec.validateResponse(op, key, response2.value);
|
|
15109
15109
|
if (errors?.length) warn(`${step.statusCode} response body does not match the schema of ${label}: ${summarize(errors)}`, responseLoc);
|
|
15110
15110
|
}
|
|
15111
15111
|
}
|
|
@@ -15180,11 +15180,14 @@ function openApiDiagnostics(file, diagram, overrides = {}) {
|
|
|
15180
15180
|
}
|
|
15181
15181
|
|
|
15182
15182
|
// src/render/usage.ts
|
|
15183
|
-
var RENDER_USAGE = ` proschi render [--out <dir>] [--format svg|md|html] <file>`;
|
|
15183
|
+
var RENDER_USAGE = ` proschi render [--out <dir>] [--format svg|md|html|hld-md|hld-html] <file>`;
|
|
15184
15184
|
var RENDER_HELP = `render Writes the diagram as static files into --out (default: the current
|
|
15185
15185
|
directory). svg (default): architecture.svg and <usecase>--<scenario>.svg
|
|
15186
15186
|
per scenario; md: <file>.md with Mermaid blocks; html: <file>.html with
|
|
15187
|
-
the SVGs
|
|
15187
|
+
the SVGs; hld-md / hld-html: <file>.hld.md / <file>.hld.html, the
|
|
15188
|
+
high-level design document (overview, requirements, capacity,
|
|
15189
|
+
components, data model, APIs, scenarios, decisions, risks).
|
|
15190
|
+
Refuses (exit 1) when the document has errors.`;
|
|
15188
15191
|
|
|
15189
15192
|
// src/fmt.ts
|
|
15190
15193
|
var import_node_fs3 = require("node:fs");
|
|
@@ -15333,6 +15336,8 @@ var IDENT_START = /[A-Za-z_]/;
|
|
|
15333
15336
|
var IDENT_PART = /[A-Za-z0-9_]/;
|
|
15334
15337
|
var TEAM_PART = /[A-Za-z0-9_-]/;
|
|
15335
15338
|
var DIGIT = /[0-9]/;
|
|
15339
|
+
var LETTER = /[A-Za-z]/;
|
|
15340
|
+
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month|usd\/GB|MB\/s|GB\/s)(?![A-Za-z0-9_/])/;
|
|
15336
15341
|
function tokenizeLine(text, line) {
|
|
15337
15342
|
const tokens = [];
|
|
15338
15343
|
const diagnostics = [];
|
|
@@ -15386,8 +15391,16 @@ function tokenizeLine(text, line) {
|
|
|
15386
15391
|
}
|
|
15387
15392
|
}
|
|
15388
15393
|
if (DIGIT.test(ch)) {
|
|
15389
|
-
|
|
15390
|
-
|
|
15394
|
+
i = readQuantity(text, i);
|
|
15395
|
+
const value = text.slice(start, i).replace(/[ \t]+/g, "");
|
|
15396
|
+
const kind = /^\d+$/.test(value) ? "number" : "quantity";
|
|
15397
|
+
tokens.push({ kind, value, col: start + 1, length: i - start });
|
|
15398
|
+
continue;
|
|
15399
|
+
}
|
|
15400
|
+
if (ch === "<" || ch === ">") {
|
|
15401
|
+
const value = text[i + 1] === "=" ? ch + "=" : ch;
|
|
15402
|
+
tokens.push({ kind: "op", value, col: start + 1, length: value.length });
|
|
15403
|
+
i += value.length;
|
|
15391
15404
|
continue;
|
|
15392
15405
|
}
|
|
15393
15406
|
if (ch === "@") {
|
|
@@ -15413,6 +15426,26 @@ function tokenizeLine(text, line) {
|
|
|
15413
15426
|
}
|
|
15414
15427
|
return { tokens, diagnostics };
|
|
15415
15428
|
}
|
|
15429
|
+
function readQuantity(text, start) {
|
|
15430
|
+
let i = start;
|
|
15431
|
+
while (i < text.length && DIGIT.test(text[i])) i++;
|
|
15432
|
+
if (text[i] === "." && DIGIT.test(text[i + 1] ?? "")) {
|
|
15433
|
+
i++;
|
|
15434
|
+
while (i < text.length && DIGIT.test(text[i])) i++;
|
|
15435
|
+
}
|
|
15436
|
+
const suffixStart = i;
|
|
15437
|
+
if (text[i] === "%") return i + 1;
|
|
15438
|
+
while (i < text.length && LETTER.test(text[i])) i++;
|
|
15439
|
+
if (i > suffixStart && text[i] === "/" && LETTER.test(text[i + 1] ?? "")) {
|
|
15440
|
+
i++;
|
|
15441
|
+
while (i < text.length && LETTER.test(text[i])) i++;
|
|
15442
|
+
}
|
|
15443
|
+
if (/^[kmb]?$/.test(text.slice(suffixStart, i))) {
|
|
15444
|
+
const unit = SEPARATE_UNIT.exec(text.slice(i));
|
|
15445
|
+
if (unit) i += unit[0].length;
|
|
15446
|
+
}
|
|
15447
|
+
return i;
|
|
15448
|
+
}
|
|
15416
15449
|
function readString(text, start) {
|
|
15417
15450
|
let value = "";
|
|
15418
15451
|
let i = start + 1;
|
|
@@ -15431,15 +15464,15 @@ function readString(text, start) {
|
|
|
15431
15464
|
}
|
|
15432
15465
|
function stripTrailingComment(text) {
|
|
15433
15466
|
let depth = 0;
|
|
15434
|
-
let
|
|
15467
|
+
let quote2 = null;
|
|
15435
15468
|
for (let i = 0; i < text.length; i++) {
|
|
15436
15469
|
const ch = text[i];
|
|
15437
|
-
if (
|
|
15470
|
+
if (quote2) {
|
|
15438
15471
|
if (ch === "\\") i++;
|
|
15439
|
-
else if (ch ===
|
|
15472
|
+
else if (ch === quote2) quote2 = null;
|
|
15440
15473
|
continue;
|
|
15441
15474
|
}
|
|
15442
|
-
if (ch === '"')
|
|
15475
|
+
if (ch === '"') quote2 = ch;
|
|
15443
15476
|
else if (ch === "{" || ch === "[") depth++;
|
|
15444
15477
|
else if (ch === "}" || ch === "]") depth = Math.max(0, depth - 1);
|
|
15445
15478
|
else if (ch === "#" && depth === 0 && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i);
|
|
@@ -15495,11 +15528,169 @@ function endpointGroupKey(endpoint, all) {
|
|
|
15495
15528
|
return method ? `${method} ${templatize(path)}` : templatize(path);
|
|
15496
15529
|
}
|
|
15497
15530
|
|
|
15531
|
+
// ../frontend/src/dsl/kinds.ts
|
|
15532
|
+
var KINDS = ["client", "edge", "cdn", "loadbalancer", "gateway", "dns", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
15533
|
+
var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
|
|
15534
|
+
var EDGE_KINDS = ["edge", "cdn", "loadbalancer", "gateway", "dns"];
|
|
15535
|
+
var BY_TECH = {
|
|
15536
|
+
"AWS CloudFront": "cdn",
|
|
15537
|
+
"Azure CDN": "cdn",
|
|
15538
|
+
"GCP Cloud CDN": "cdn",
|
|
15539
|
+
"Azure Front Door": "cdn",
|
|
15540
|
+
"AWS Load Balancer": "loadbalancer",
|
|
15541
|
+
"GCP Load Balancing": "loadbalancer",
|
|
15542
|
+
"AWS API Gateway": "gateway",
|
|
15543
|
+
"Azure API Management": "gateway",
|
|
15544
|
+
"AWS Route53": "dns",
|
|
15545
|
+
"Azure DNS": "dns",
|
|
15546
|
+
"GCP Cloud DNS": "dns",
|
|
15547
|
+
Redis: "cache",
|
|
15548
|
+
Elasticsearch: "search",
|
|
15549
|
+
"GCP BigQuery": "analytics",
|
|
15550
|
+
InfluxDB: "analytics",
|
|
15551
|
+
TimescaleDB: "analytics",
|
|
15552
|
+
// A note shape annotates the diagram; it is not a client.
|
|
15553
|
+
Note: "other"
|
|
15554
|
+
};
|
|
15555
|
+
var BY_TYPE = {
|
|
15556
|
+
shape: "client",
|
|
15557
|
+
cdn: "edge",
|
|
15558
|
+
service: "service",
|
|
15559
|
+
compute: "service",
|
|
15560
|
+
container: "service",
|
|
15561
|
+
serverless: "function",
|
|
15562
|
+
cache: "cache",
|
|
15563
|
+
database: "database",
|
|
15564
|
+
queue: "queue",
|
|
15565
|
+
storage: "storage",
|
|
15566
|
+
external: "external"
|
|
15567
|
+
};
|
|
15568
|
+
function kindOf(node) {
|
|
15569
|
+
if (node.kind !== "component") return "other";
|
|
15570
|
+
return BY_TECH[node.techStack] ?? BY_TYPE[node.type] ?? "other";
|
|
15571
|
+
}
|
|
15572
|
+
function isKind(word) {
|
|
15573
|
+
return KINDS.includes(word);
|
|
15574
|
+
}
|
|
15575
|
+
function isDataStore(kind) {
|
|
15576
|
+
return DATA_STORE_KINDS.includes(kind);
|
|
15577
|
+
}
|
|
15578
|
+
function isEdge(kind) {
|
|
15579
|
+
return EDGE_KINDS.includes(kind);
|
|
15580
|
+
}
|
|
15581
|
+
function kindMatches(actual, wanted) {
|
|
15582
|
+
return actual === wanted || wanted === "edge" && isEdge(actual);
|
|
15583
|
+
}
|
|
15584
|
+
|
|
15585
|
+
// ../frontend/src/dsl/quantity.ts
|
|
15586
|
+
function parseQuantity(text) {
|
|
15587
|
+
const m = /^(\d+(?:\.\d+)?)(.*)$/.exec(text.replace(/\s+/g, ""));
|
|
15588
|
+
if (!m) return { error: `'${text}' is not a number` };
|
|
15589
|
+
const number = Number(m[1]);
|
|
15590
|
+
const suffix = m[2];
|
|
15591
|
+
const unit = UNITS.get(suffix);
|
|
15592
|
+
if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
|
|
15593
|
+
const magnitude = MAGNITUDES.get(suffix[0]);
|
|
15594
|
+
const rest = suffix.slice(1);
|
|
15595
|
+
const after2 = UNITS.get(rest);
|
|
15596
|
+
if (magnitude && (rest === "" || after2)) {
|
|
15597
|
+
return after2 ? { value: round(number * magnitude * after2.factor), unit: after2.unit } : { value: round(number * magnitude) };
|
|
15598
|
+
}
|
|
15599
|
+
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)` };
|
|
15600
|
+
}
|
|
15601
|
+
var round = (n) => Number(n.toPrecision(12));
|
|
15602
|
+
var MAGNITUDES = /* @__PURE__ */ new Map([
|
|
15603
|
+
["k", 1e3],
|
|
15604
|
+
["m", 1e6],
|
|
15605
|
+
["b", 1e9]
|
|
15606
|
+
]);
|
|
15607
|
+
var UNITS = /* @__PURE__ */ new Map([
|
|
15608
|
+
["rps", { unit: "rps", factor: 1 }],
|
|
15609
|
+
["rpm", { unit: "rps", factor: 1 / 60 }],
|
|
15610
|
+
["rpd", { unit: "rps", factor: 1 / 86400 }],
|
|
15611
|
+
["ms", { unit: "ms", factor: 1 }],
|
|
15612
|
+
["s", { unit: "ms", factor: 1e3 }],
|
|
15613
|
+
["%", { unit: "%", factor: 1 }],
|
|
15614
|
+
["usd/month", { unit: "usd/month", factor: 1 }],
|
|
15615
|
+
["MB/s", { unit: "MBps", factor: 1 }],
|
|
15616
|
+
["GB/s", { unit: "MBps", factor: 1e3 }],
|
|
15617
|
+
["usd/GB", { unit: "usd/GB", factor: 1 }]
|
|
15618
|
+
]);
|
|
15619
|
+
|
|
15620
|
+
// ../frontend/src/dsl/access.ts
|
|
15621
|
+
var WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
15622
|
+
var WRITE_VERBS = [
|
|
15623
|
+
"INSERT",
|
|
15624
|
+
"UPDATE",
|
|
15625
|
+
"UPSERT",
|
|
15626
|
+
"DELETE",
|
|
15627
|
+
"PUT",
|
|
15628
|
+
"SET",
|
|
15629
|
+
"WRITE",
|
|
15630
|
+
"APPEND",
|
|
15631
|
+
"INCR",
|
|
15632
|
+
"DECR",
|
|
15633
|
+
"LPUSH",
|
|
15634
|
+
"RPUSH",
|
|
15635
|
+
"ZADD",
|
|
15636
|
+
"HSET",
|
|
15637
|
+
"GEOADD",
|
|
15638
|
+
"PUBLISH",
|
|
15639
|
+
"SEND",
|
|
15640
|
+
"ENQUEUE",
|
|
15641
|
+
"PRODUCE",
|
|
15642
|
+
"CHARGE",
|
|
15643
|
+
"CREATE",
|
|
15644
|
+
// DynamoDB and Redis commands that store data
|
|
15645
|
+
"PUTITEM",
|
|
15646
|
+
"UPDATEITEM",
|
|
15647
|
+
"DELETEITEM",
|
|
15648
|
+
"BATCHWRITEITEM",
|
|
15649
|
+
"INCRBY",
|
|
15650
|
+
"HINCRBY",
|
|
15651
|
+
"SADD",
|
|
15652
|
+
"XADD",
|
|
15653
|
+
"MSET",
|
|
15654
|
+
// Plain words for storing something
|
|
15655
|
+
"SAVE",
|
|
15656
|
+
"STORE",
|
|
15657
|
+
"UPLOAD",
|
|
15658
|
+
"COMMIT",
|
|
15659
|
+
"RECORD",
|
|
15660
|
+
"MARK",
|
|
15661
|
+
// Claiming a resource changes state: a seat, a driver, stock
|
|
15662
|
+
"RESERVE",
|
|
15663
|
+
"HOLD",
|
|
15664
|
+
"BOOK",
|
|
15665
|
+
// Handing something on: a write at the receiver (a queue, a provider), like SEND and PUBLISH
|
|
15666
|
+
"EMIT",
|
|
15667
|
+
"NOTIFY"
|
|
15668
|
+
];
|
|
15669
|
+
function accessOf(httpMethod, label) {
|
|
15670
|
+
if (httpMethod) return WRITE_METHODS.includes(httpMethod.toUpperCase()) ? "write" : "read";
|
|
15671
|
+
const word = /^\s*"?([A-Za-z_]+)/.exec(label)?.[1];
|
|
15672
|
+
return word && WRITE_VERBS.includes(word.toUpperCase()) ? "write" : "read";
|
|
15673
|
+
}
|
|
15674
|
+
|
|
15498
15675
|
// ../frontend/src/dsl/parser.ts
|
|
15499
15676
|
var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
15500
15677
|
var DEFAULT_TECH = "Rectangle";
|
|
15501
15678
|
var DEFAULT_GROUP_TECH = "Logical Group";
|
|
15502
15679
|
var MAX_SCENARIOS = 32;
|
|
15680
|
+
var REPLICAS = /^x(\d+)$/;
|
|
15681
|
+
var PERCENTILES = { p50: 50, p90: 90, p95: 95, p99: 99, p999: 99.9 };
|
|
15682
|
+
var FIELD_FLAGS = ["key", "index", "unique", "optional"];
|
|
15683
|
+
var UNIT_EXAMPLES = {
|
|
15684
|
+
rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
|
|
15685
|
+
ms: "a duration, e.g. 50ms or 1.5s",
|
|
15686
|
+
"%": "a percentage, e.g. 99.9%",
|
|
15687
|
+
"usd/month": "a monthly cost, e.g. 400 usd/month",
|
|
15688
|
+
MBps: "a bandwidth, e.g. 500 MB/s or 1 GB/s",
|
|
15689
|
+
"usd/GB": "a price per gigabyte, e.g. 0.05 usd/GB"
|
|
15690
|
+
};
|
|
15691
|
+
var CAPACITY_PARTS = "a rate, reads, writes, shards, latency, availability, cost, durable or volatile, consistency, bandwidth or egress";
|
|
15692
|
+
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';
|
|
15693
|
+
var ASSERTION_VERBS = "calls, every scenario calls, never calls, never waits for, writes, responds, starts at, has scenario or handles failure of";
|
|
15503
15694
|
var techByName = new Map(
|
|
15504
15695
|
componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
|
|
15505
15696
|
);
|
|
@@ -15527,6 +15718,16 @@ var Parser = class {
|
|
|
15527
15718
|
/** `a|b` for every connection, both directions; filled before use cases are built. */
|
|
15528
15719
|
connected = /* @__PURE__ */ new Set();
|
|
15529
15720
|
title;
|
|
15721
|
+
summary;
|
|
15722
|
+
traffic = [];
|
|
15723
|
+
requirements = [];
|
|
15724
|
+
capacity = [];
|
|
15725
|
+
entities = [];
|
|
15726
|
+
decisions = [];
|
|
15727
|
+
tests = [];
|
|
15728
|
+
/** Checks of use case, scenario and node names, run once every file is read and use cases are built. */
|
|
15729
|
+
deferred = [];
|
|
15730
|
+
built = [];
|
|
15530
15731
|
options;
|
|
15531
15732
|
constructor(source2, options) {
|
|
15532
15733
|
this.options = options;
|
|
@@ -15541,11 +15742,21 @@ var Parser = class {
|
|
|
15541
15742
|
const useCases = this.useCases.map(({ useCase, body }) => this.buildUseCase(useCase, body));
|
|
15542
15743
|
const endpoints = useCases.flatMap((u) => u.endpoint ? [u.endpoint] : []);
|
|
15543
15744
|
for (const u of useCases) if (u.endpoint) u.endpointGroup = endpointGroupKey(u.endpoint, endpoints);
|
|
15745
|
+
this.built = useCases;
|
|
15746
|
+
for (const check of this.deferred) check();
|
|
15544
15747
|
const diagram = {
|
|
15545
15748
|
title: this.title,
|
|
15546
15749
|
nodes: [...this.nodes.values()],
|
|
15547
15750
|
edges: this.edges,
|
|
15548
|
-
useCases
|
|
15751
|
+
useCases,
|
|
15752
|
+
// The HLD sections are left out when a document has none, so older documents parse exactly as before.
|
|
15753
|
+
...this.summary !== void 0 ? { summary: this.summary } : {},
|
|
15754
|
+
...this.traffic.length ? { traffic: this.traffic } : {},
|
|
15755
|
+
...this.requirements.length ? { requirements: this.requirements } : {},
|
|
15756
|
+
...this.capacity.length ? { capacity: this.capacity } : {},
|
|
15757
|
+
...this.entities.length ? { entities: this.entities } : {},
|
|
15758
|
+
...this.decisions.length ? { decisions: this.decisions } : {},
|
|
15759
|
+
...this.tests.length ? { tests: this.tests } : {}
|
|
15549
15760
|
};
|
|
15550
15761
|
const order = (d) => this.fileOrder.get(d.file) ?? 0;
|
|
15551
15762
|
this.diagnostics.sort((a, b) => order(a) - order(b) || a.line - b.line || a.col - b.col);
|
|
@@ -15557,7 +15768,7 @@ var Parser = class {
|
|
|
15557
15768
|
i = this.parseLine(i);
|
|
15558
15769
|
}
|
|
15559
15770
|
for (const frame of this.stack) {
|
|
15560
|
-
const what = frame.kind === "group" ? `group '${frame.id}'` : frame.kind === "usecase" ? `usecase '${frame.useCase.name}'` : frame.kind === "alt" ? `alt '${frame.name}'` : "par block";
|
|
15771
|
+
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);
|
|
15561
15772
|
this.error(`Missing } to close ${what}`, frame.loc);
|
|
15562
15773
|
}
|
|
15563
15774
|
}
|
|
@@ -15567,8 +15778,19 @@ var Parser = class {
|
|
|
15567
15778
|
const { tokens, diagnostics } = tokenizeLine(this.lines[index], line);
|
|
15568
15779
|
this.diagnostics.push(...diagnostics.map((d) => ({ ...d, ...this.fileField() })));
|
|
15569
15780
|
if (tokens.length === 0 || diagnostics.some((d) => d.severity === "error")) return index;
|
|
15570
|
-
const [first, second] = tokens;
|
|
15781
|
+
const [first, second, third] = tokens;
|
|
15571
15782
|
const loc = (t) => this.locOf(t, line);
|
|
15783
|
+
const top = this.top();
|
|
15784
|
+
if (top?.kind === "section") {
|
|
15785
|
+
if (first.kind === "rbrace") {
|
|
15786
|
+
this.stack.pop();
|
|
15787
|
+
if (!top.discard) this.closeSection(top);
|
|
15788
|
+
if (second) this.error("Unexpected input after }", loc(second));
|
|
15789
|
+
} else if (!top.discard) {
|
|
15790
|
+
this.parseSectionLine(top, tokens, line);
|
|
15791
|
+
}
|
|
15792
|
+
return index;
|
|
15793
|
+
}
|
|
15572
15794
|
if (first.kind === "rbrace") {
|
|
15573
15795
|
if (this.stack.length === 0) this.error("Unmatched }", loc(first));
|
|
15574
15796
|
else this.closeFrame();
|
|
@@ -15602,6 +15824,26 @@ var Parser = class {
|
|
|
15602
15824
|
case "import":
|
|
15603
15825
|
this.parseImport(tokens, line);
|
|
15604
15826
|
break;
|
|
15827
|
+
// These words are keywords only in the shape of their statement, so older documents may still use them as ids.
|
|
15828
|
+
case "traffic":
|
|
15829
|
+
case "requirements":
|
|
15830
|
+
case "capacity":
|
|
15831
|
+
if (second?.kind === "lbrace") this.openSection(first.value, tokens, line);
|
|
15832
|
+
else this.parseNode(tokens, line);
|
|
15833
|
+
break;
|
|
15834
|
+
case "entity":
|
|
15835
|
+
if (second?.kind === "ident" && second.value !== "pos" && !REPLICAS.test(second.value)) this.parseEntity(tokens, line);
|
|
15836
|
+
else this.parseNode(tokens, line);
|
|
15837
|
+
break;
|
|
15838
|
+
case "decision":
|
|
15839
|
+
if (second?.kind === "string" && (third?.kind === "lbrace" || third?.kind === "ident" && third.value !== "pos" && !REPLICAS.test(third.value)))
|
|
15840
|
+
this.parseDecision(tokens, line);
|
|
15841
|
+
else this.parseNode(tokens, line);
|
|
15842
|
+
break;
|
|
15843
|
+
case "test":
|
|
15844
|
+
if (second?.kind === "string" && tokens[tokens.length - 1].kind === "lbrace") this.parseTest(tokens, line);
|
|
15845
|
+
else this.parseNode(tokens, line);
|
|
15846
|
+
break;
|
|
15605
15847
|
default:
|
|
15606
15848
|
this.parseNode(tokens, line);
|
|
15607
15849
|
}
|
|
@@ -15613,8 +15855,12 @@ var Parser = class {
|
|
|
15613
15855
|
this.error('Expected a title, e.g. title "My System"', this.locOf(tokens[0], line));
|
|
15614
15856
|
return;
|
|
15615
15857
|
}
|
|
15616
|
-
|
|
15617
|
-
this.
|
|
15858
|
+
const summary = tokens[2]?.kind === "string" ? tokens[2] : void 0;
|
|
15859
|
+
if (this.file === void 0) {
|
|
15860
|
+
this.title = value.value;
|
|
15861
|
+
if (summary) this.summary = summary.value;
|
|
15862
|
+
}
|
|
15863
|
+
this.expectEnd(tokens, summary ? 3 : 2, line);
|
|
15618
15864
|
}
|
|
15619
15865
|
parseImport(tokens, line) {
|
|
15620
15866
|
const keyword = tokens[0];
|
|
@@ -15823,6 +16069,13 @@ var Parser = class {
|
|
|
15823
16069
|
}
|
|
15824
16070
|
node.position = { x: Number(x.value), y: Number(y.value) };
|
|
15825
16071
|
i += 3;
|
|
16072
|
+
} else if (t.kind === "ident" && REPLICAS.test(t.value)) {
|
|
16073
|
+
const replicas = Number(REPLICAS.exec(t.value)[1]);
|
|
16074
|
+
if (replicas < 1) {
|
|
16075
|
+
this.error("A node needs at least one replica: x1, x2, \u2026", this.locOf(t, line));
|
|
16076
|
+
return;
|
|
16077
|
+
}
|
|
16078
|
+
node.replicas = replicas;
|
|
15826
16079
|
} else if (t.kind === "label" || t.kind === "arrow") {
|
|
15827
16080
|
this.error(`Expected '${idToken.value} -> target' for a connection`, this.locOf(t, line));
|
|
15828
16081
|
return;
|
|
@@ -15868,7 +16121,20 @@ var Parser = class {
|
|
|
15868
16121
|
if (top?.kind === "usecase" || top?.kind === "alt" || top?.kind === "par") {
|
|
15869
16122
|
const body = this.currentBody();
|
|
15870
16123
|
body.openAlt = void 0;
|
|
15871
|
-
|
|
16124
|
+
const prefixes = arrow === "-->" ? { rest: label } : this.readLabelPrefixes(label, labelToken, line);
|
|
16125
|
+
body.items.push({
|
|
16126
|
+
kind: "step",
|
|
16127
|
+
step: {
|
|
16128
|
+
from,
|
|
16129
|
+
to,
|
|
16130
|
+
arrow,
|
|
16131
|
+
label: prefixes.rest,
|
|
16132
|
+
parallelGroup: top.kind === "par" ? top.group : void 0,
|
|
16133
|
+
...prefixes.multiplier !== void 0 ? { multiplier: prefixes.multiplier } : {},
|
|
16134
|
+
...prefixes.sizeBytes !== void 0 ? { sizeBytes: prefixes.sizeBytes } : {},
|
|
16135
|
+
loc
|
|
16136
|
+
}
|
|
16137
|
+
});
|
|
15872
16138
|
} else if (arrow === "-x") {
|
|
15873
16139
|
this.error("'-x' marks a failed call and is only allowed in use case steps", this.locOf(arrowToken, line));
|
|
15874
16140
|
} else {
|
|
@@ -15878,6 +16144,751 @@ var Parser = class {
|
|
|
15878
16144
|
}
|
|
15879
16145
|
return lastLine;
|
|
15880
16146
|
}
|
|
16147
|
+
/**
|
|
16148
|
+
* Reads `x<N>` (fan-out) and `~<size>` (payload size) at the start of a step
|
|
16149
|
+
* label, in either order (§7.3), and returns the label without them. A
|
|
16150
|
+
* prefix must be followed by whitespace or end the label, so `xml …` and
|
|
16151
|
+
* `x-request-id` are ordinary labels. Problems are reported at the prefix.
|
|
16152
|
+
*/
|
|
16153
|
+
readLabelPrefixes(label, token, line) {
|
|
16154
|
+
const out = { rest: label };
|
|
16155
|
+
if (!token) return out;
|
|
16156
|
+
const after2 = this.lines[line - 1].slice(token.col);
|
|
16157
|
+
const base = token.col + 1 + (after2.length - after2.trimStart().length);
|
|
16158
|
+
let offset = 0;
|
|
16159
|
+
for (; ; ) {
|
|
16160
|
+
const rest = label.slice(offset);
|
|
16161
|
+
const at = (length) => ({ line, col: base + offset, length, ...this.fileField() });
|
|
16162
|
+
const fanOut = /^x(\d+)(?=\s|$)/.exec(rest);
|
|
16163
|
+
const size = rest.startsWith("~") ? /^~(\S*)/.exec(rest) : null;
|
|
16164
|
+
const match = fanOut ?? size;
|
|
16165
|
+
if (!match) break;
|
|
16166
|
+
if (fanOut) {
|
|
16167
|
+
const n = Number(fanOut[1]);
|
|
16168
|
+
if (out.multiplier !== void 0) this.error("The fan-out is given twice; write one x<N>", at(match[0].length));
|
|
16169
|
+
else if (n < 1) this.error("A fan-out needs at least one call per request: x1, x2, \u2026", at(match[0].length));
|
|
16170
|
+
else out.multiplier = n;
|
|
16171
|
+
} else {
|
|
16172
|
+
const bytes = parseSize(size[1]);
|
|
16173
|
+
if (out.sizeBytes !== void 0) this.error("The payload size is given twice; write one ~<size>", at(match[0].length));
|
|
16174
|
+
else if ("error" in bytes) this.error(bytes.error, at(match[0].length));
|
|
16175
|
+
else out.sizeBytes = bytes.value;
|
|
16176
|
+
}
|
|
16177
|
+
offset += match[0].length;
|
|
16178
|
+
offset += label.slice(offset).length - label.slice(offset).trimStart().length;
|
|
16179
|
+
}
|
|
16180
|
+
out.rest = label.slice(offset);
|
|
16181
|
+
return out;
|
|
16182
|
+
}
|
|
16183
|
+
// ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
|
|
16184
|
+
/**
|
|
16185
|
+
* Starts a section block. Sections are top level only; a misplaced one, or
|
|
16186
|
+
* one whose header is broken, still opens a block so that its lines and its
|
|
16187
|
+
* `}` are not mistaken for nodes and the end of an enclosing block.
|
|
16188
|
+
*/
|
|
16189
|
+
openSection(section, tokens, line, fill = {}) {
|
|
16190
|
+
const keyword = tokens[0];
|
|
16191
|
+
const loc = this.locOf(keyword, line);
|
|
16192
|
+
if (this.stack.length > 0) {
|
|
16193
|
+
this.error(`${section} is only allowed at the top level`, loc);
|
|
16194
|
+
this.skipSection(section, tokens, line);
|
|
16195
|
+
return false;
|
|
16196
|
+
}
|
|
16197
|
+
if (section === "traffic" || section === "requirements" || section === "capacity") this.expectEnd(tokens, 2, line);
|
|
16198
|
+
this.stack.push({ kind: "section", section, ...fill, loc });
|
|
16199
|
+
return true;
|
|
16200
|
+
}
|
|
16201
|
+
/** After an error in a section header: skips the block, if the line opens one. */
|
|
16202
|
+
skipSection(section, tokens, line) {
|
|
16203
|
+
if (tokens[tokens.length - 1].kind === "lbrace") this.stack.push({ kind: "section", section, discard: true, loc: this.locOf(tokens[0], line) });
|
|
16204
|
+
}
|
|
16205
|
+
closeSection(frame) {
|
|
16206
|
+
if (frame.test && frame.test.assertions.length === 0) this.warning(`Test '${frame.test.name}' has no assertions`, frame.test.loc);
|
|
16207
|
+
}
|
|
16208
|
+
parseSectionLine(frame, tokens, line) {
|
|
16209
|
+
switch (frame.section) {
|
|
16210
|
+
case "traffic":
|
|
16211
|
+
return this.parseTrafficLine(tokens, line);
|
|
16212
|
+
case "requirements":
|
|
16213
|
+
return this.parseRequirement(tokens, line);
|
|
16214
|
+
case "capacity":
|
|
16215
|
+
return this.parseCapacityLine(tokens, line);
|
|
16216
|
+
case "entity":
|
|
16217
|
+
return this.parseField(frame.entity, tokens, line);
|
|
16218
|
+
case "decision":
|
|
16219
|
+
return this.parseDecisionLine(frame.decision, tokens, line);
|
|
16220
|
+
case "test": {
|
|
16221
|
+
const assertion2 = this.parseAssertion(tokens, line);
|
|
16222
|
+
if (assertion2) frame.test.assertions.push(assertion2);
|
|
16223
|
+
return;
|
|
16224
|
+
}
|
|
16225
|
+
}
|
|
16226
|
+
}
|
|
16227
|
+
/** `"Redirect" 100k rps mix "Cache hit" 90%, "Cache miss" 10%` */
|
|
16228
|
+
parseTrafficLine(tokens, line) {
|
|
16229
|
+
const [name, rate] = tokens;
|
|
16230
|
+
if (name.kind !== "string") {
|
|
16231
|
+
this.error('Expected a use case name in quotes and a rate, e.g. "Redirect" 100k rps', this.locOf(name, line));
|
|
16232
|
+
return;
|
|
16233
|
+
}
|
|
16234
|
+
const rps = this.quantityOf(rate, "rps", name, line);
|
|
16235
|
+
if (rps === void 0) return;
|
|
16236
|
+
const entry = { useCase: name.value, rps, loc: this.lineLoc(tokens, line) };
|
|
16237
|
+
const shares = [];
|
|
16238
|
+
let i = 2;
|
|
16239
|
+
if (tokens[i]) {
|
|
16240
|
+
const mix = tokens[i++];
|
|
16241
|
+
if (!isWord(mix, "mix")) {
|
|
16242
|
+
this.error(`Unexpected ${describe(mix)}; expected mix "Scenario" 90%, "Other" 10%`, this.locOf(mix, line));
|
|
16243
|
+
return;
|
|
16244
|
+
}
|
|
16245
|
+
for (; ; ) {
|
|
16246
|
+
const scenario = tokens[i];
|
|
16247
|
+
if (scenario?.kind !== "string") {
|
|
16248
|
+
this.error('Expected a scenario name in quotes and its share, e.g. "Cache hit" 90%', this.locOf(scenario ?? tokens[i - 1], line));
|
|
16249
|
+
return;
|
|
16250
|
+
}
|
|
16251
|
+
const share = this.quantityOf(tokens[i + 1], "%", scenario, line);
|
|
16252
|
+
if (share === void 0) return;
|
|
16253
|
+
const loc = this.locOf(scenario, line);
|
|
16254
|
+
if (shares.some((s) => s.scenario === scenario.value)) {
|
|
16255
|
+
this.error(`Scenario '${scenario.value}' is in the mix twice`, loc);
|
|
16256
|
+
return;
|
|
16257
|
+
}
|
|
16258
|
+
shares.push({ scenario: scenario.value, share, loc });
|
|
16259
|
+
i += 2;
|
|
16260
|
+
if (!tokens[i]) break;
|
|
16261
|
+
if (tokens[i].kind !== "comma") {
|
|
16262
|
+
this.error(`Expected , between mix shares, not ${describe(tokens[i])}`, this.locOf(tokens[i], line));
|
|
16263
|
+
return;
|
|
16264
|
+
}
|
|
16265
|
+
i++;
|
|
16266
|
+
}
|
|
16267
|
+
const total = shares.reduce((sum, s) => sum + s.share, 0);
|
|
16268
|
+
if (total <= 0) {
|
|
16269
|
+
this.error("Mix shares must add up to more than 0%", this.locOf(mix, line));
|
|
16270
|
+
return;
|
|
16271
|
+
}
|
|
16272
|
+
if (Math.abs(total - 100) > 1e-9) {
|
|
16273
|
+
this.warning(`Mix shares add up to ${Number(total.toFixed(3))}%, not 100%; they are scaled to fit`, this.locOf(mix, line));
|
|
16274
|
+
}
|
|
16275
|
+
entry.mix = shares.map((s) => ({ scenario: s.scenario, share: s.share / total }));
|
|
16276
|
+
}
|
|
16277
|
+
const nameLoc = this.locOf(name, line);
|
|
16278
|
+
const first = this.traffic.find((t) => t.useCase === entry.useCase);
|
|
16279
|
+
if (first) {
|
|
16280
|
+
this.error(`Duplicate traffic for '${entry.useCase}' (first ${this.where(first.loc, nameLoc)})`, nameLoc);
|
|
16281
|
+
return;
|
|
16282
|
+
}
|
|
16283
|
+
this.traffic.push(entry);
|
|
16284
|
+
this.deferred.push(() => {
|
|
16285
|
+
const useCase = this.checkUseCase(entry.useCase, nameLoc);
|
|
16286
|
+
if (useCase) for (const s of shares) this.checkScenario(useCase, s.scenario, s.loc);
|
|
16287
|
+
});
|
|
16288
|
+
}
|
|
16289
|
+
/** One line of `requirements { … }`. */
|
|
16290
|
+
parseRequirement(tokens, line) {
|
|
16291
|
+
const [first] = tokens;
|
|
16292
|
+
const loc = this.lineLoc(tokens, line);
|
|
16293
|
+
const help = 'Expected a requirement: p99 "Use case" < 50ms, availability >= 99.9%, durable "Use case", survive any node failure, cost <= 3000 usd/month';
|
|
16294
|
+
if (first.kind !== "ident") {
|
|
16295
|
+
this.error(help, this.locOf(first, line));
|
|
16296
|
+
return;
|
|
16297
|
+
}
|
|
16298
|
+
const word = first.value;
|
|
16299
|
+
let i = 1;
|
|
16300
|
+
let useCase;
|
|
16301
|
+
const readUseCase = () => {
|
|
16302
|
+
if (tokens[i]?.kind !== "string") return;
|
|
16303
|
+
useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
|
|
16304
|
+
i++;
|
|
16305
|
+
};
|
|
16306
|
+
const commit = (requirement2, scenario) => {
|
|
16307
|
+
this.requirements.push(requirement2);
|
|
16308
|
+
const named = useCase;
|
|
16309
|
+
if (named)
|
|
16310
|
+
this.deferred.push(() => {
|
|
16311
|
+
const found = this.checkUseCase(named.name, named.loc);
|
|
16312
|
+
if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
|
|
16313
|
+
});
|
|
16314
|
+
};
|
|
16315
|
+
const withUseCase = () => useCase ? { useCase: useCase.name } : {};
|
|
16316
|
+
if (/^p\d+$/.test(word)) {
|
|
16317
|
+
const percentile = PERCENTILES[word];
|
|
16318
|
+
if (percentile === void 0) {
|
|
16319
|
+
this.error(`Unknown percentile '${word}'; use p50, p90, p95, p99 or p999`, this.locOf(first, line));
|
|
16320
|
+
return;
|
|
16321
|
+
}
|
|
16322
|
+
readUseCase();
|
|
16323
|
+
let scenario;
|
|
16324
|
+
if (isWord(tokens[i], "scenario")) {
|
|
16325
|
+
const name = tokens[i + 1];
|
|
16326
|
+
if (!useCase) {
|
|
16327
|
+
this.error(`A scenario belongs to a use case; write ${word} "Use case" scenario "Scenario" < 100ms`, this.locOf(tokens[i], line));
|
|
16328
|
+
return;
|
|
16329
|
+
}
|
|
16330
|
+
if (name?.kind !== "string") {
|
|
16331
|
+
this.error("Expected a scenario name in quotes after scenario", this.locOf(name ?? tokens[i], line));
|
|
16332
|
+
return;
|
|
16333
|
+
}
|
|
16334
|
+
scenario = { name: name.value, loc: this.locOf(name, line) };
|
|
16335
|
+
i += 2;
|
|
16336
|
+
}
|
|
16337
|
+
if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
|
|
16338
|
+
const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
|
|
16339
|
+
if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
|
|
16340
|
+
commit({ kind: "latency", percentile, ...withUseCase(), ...scenario ? { scenario: scenario.name } : {}, maxMs, loc }, scenario);
|
|
16341
|
+
return;
|
|
16342
|
+
}
|
|
16343
|
+
switch (word) {
|
|
16344
|
+
case "availability": {
|
|
16345
|
+
readUseCase();
|
|
16346
|
+
if (!this.expectOp(tokens, i, [">=", ">"], 'availability "Use case" >= 99.9%', line)) return;
|
|
16347
|
+
const minPercent = this.quantityOf(tokens[i + 1], "%", tokens[i], line);
|
|
16348
|
+
if (minPercent === void 0) return;
|
|
16349
|
+
if (minPercent > 100) {
|
|
16350
|
+
this.error("Availability cannot be more than 100%", this.locOf(tokens[i + 1], line));
|
|
16351
|
+
return;
|
|
16352
|
+
}
|
|
16353
|
+
if (!this.expectEnd(tokens, i + 2, line)) return;
|
|
16354
|
+
commit({ kind: "availability", ...withUseCase(), minPercent, loc });
|
|
16355
|
+
return;
|
|
16356
|
+
}
|
|
16357
|
+
case "durable":
|
|
16358
|
+
readUseCase();
|
|
16359
|
+
if (!useCase) {
|
|
16360
|
+
this.error('Expected a use case name in quotes, e.g. durable "Shorten"', this.locOf(tokens[1] ?? first, line));
|
|
16361
|
+
return;
|
|
16362
|
+
}
|
|
16363
|
+
if (!this.expectEnd(tokens, i, line)) return;
|
|
16364
|
+
commit({ kind: "durable", useCase: useCase.name, loc });
|
|
16365
|
+
return;
|
|
16366
|
+
case "survive": {
|
|
16367
|
+
if (isWord(tokens[1], "any") && isWord(tokens[2], "node") && isWord(tokens[3], "failure")) {
|
|
16368
|
+
if (this.expectEnd(tokens, 4, line)) commit({ kind: "survive", target: "any", loc });
|
|
16369
|
+
return;
|
|
16370
|
+
}
|
|
16371
|
+
if (!isWord(tokens[1], "failure") || !isWord(tokens[2], "of")) {
|
|
16372
|
+
this.error("Expected survive any node failure, or survive failure of <node | [Tech] | any kind>", this.locOf(tokens[1] ?? first, line));
|
|
16373
|
+
return;
|
|
16374
|
+
}
|
|
16375
|
+
const target = this.parseSelector(tokens, 3, line);
|
|
16376
|
+
if (!target || !this.expectEnd(tokens, target.next, line)) return;
|
|
16377
|
+
commit({ kind: "survive", target: target.selector, loc });
|
|
16378
|
+
this.checkSelectorLater(target);
|
|
16379
|
+
return;
|
|
16380
|
+
}
|
|
16381
|
+
case "cost": {
|
|
16382
|
+
if (!this.expectOp(tokens, 1, ["<=", "<"], "cost <= 3000 usd/month", line)) return;
|
|
16383
|
+
const maxUsdPerMonth = this.quantityOf(tokens[2], "usd/month", tokens[1], line);
|
|
16384
|
+
if (maxUsdPerMonth === void 0 || !this.expectEnd(tokens, 3, line)) return;
|
|
16385
|
+
commit({ kind: "cost", maxUsdPerMonth, loc });
|
|
16386
|
+
return;
|
|
16387
|
+
}
|
|
16388
|
+
default:
|
|
16389
|
+
this.error(`Unknown requirement '${word}'. ${help}`, this.locOf(first, line));
|
|
16390
|
+
}
|
|
16391
|
+
}
|
|
16392
|
+
/** `db 20k rps latency 4ms availability 99.95% cost 400 usd/month durable`, parts in any order. */
|
|
16393
|
+
parseCapacityLine(tokens, line) {
|
|
16394
|
+
const [id] = tokens;
|
|
16395
|
+
if (id.kind !== "ident") {
|
|
16396
|
+
this.error("Expected a node id and its overrides, e.g. db 20k rps latency 4ms", this.locOf(id, line));
|
|
16397
|
+
return;
|
|
16398
|
+
}
|
|
16399
|
+
const override = { node: id.value, loc: this.lineLoc(tokens, line) };
|
|
16400
|
+
const seen = /* @__PURE__ */ new Set();
|
|
16401
|
+
for (let i = 1; i < tokens.length; ) {
|
|
16402
|
+
const t = tokens[i];
|
|
16403
|
+
let part;
|
|
16404
|
+
if (t.kind === "quantity" || t.kind === "number") {
|
|
16405
|
+
part = "rate";
|
|
16406
|
+
const rps = this.quantityOf(t, "rps", t, line);
|
|
16407
|
+
if (rps === void 0) return;
|
|
16408
|
+
override.rps = rps;
|
|
16409
|
+
i++;
|
|
16410
|
+
} else if (isWord(t, "latency") || isWord(t, "availability") || isWord(t, "cost")) {
|
|
16411
|
+
part = t.value;
|
|
16412
|
+
const unit = part === "latency" ? "ms" : part === "availability" ? "%" : "usd/month";
|
|
16413
|
+
const value = this.quantityOf(tokens[i + 1], unit, t, line);
|
|
16414
|
+
if (value === void 0) return;
|
|
16415
|
+
if (part === "latency") override.latencyMs = value;
|
|
16416
|
+
else if (part === "availability") override.availability = value;
|
|
16417
|
+
else override.costUsd = value;
|
|
16418
|
+
i += 2;
|
|
16419
|
+
} else if (isWord(t, "durable") || isWord(t, "volatile")) {
|
|
16420
|
+
part = "durable or volatile";
|
|
16421
|
+
override.durable = t.value === "durable";
|
|
16422
|
+
i++;
|
|
16423
|
+
} else if (isWord(t, "reads") || isWord(t, "writes")) {
|
|
16424
|
+
part = t.value;
|
|
16425
|
+
const value = this.quantityOf(tokens[i + 1], "rps", t, line);
|
|
16426
|
+
if (value === void 0) return;
|
|
16427
|
+
if (part === "reads") override.readRps = value;
|
|
16428
|
+
else override.writeRps = value;
|
|
16429
|
+
i += 2;
|
|
16430
|
+
} else if (isWord(t, "shards")) {
|
|
16431
|
+
part = "shards";
|
|
16432
|
+
const n = tokens[i + 1];
|
|
16433
|
+
if (n?.kind !== "number" || Number(n.value) < 1) {
|
|
16434
|
+
this.error("Expected a whole number of shards, at least 1, e.g. shards 4", this.locOf(n ?? t, line));
|
|
16435
|
+
return;
|
|
16436
|
+
}
|
|
16437
|
+
override.shards = Number(n.value);
|
|
16438
|
+
i += 2;
|
|
16439
|
+
} else if (isWord(t, "consistency")) {
|
|
16440
|
+
part = "consistency";
|
|
16441
|
+
const value = tokens[i + 1];
|
|
16442
|
+
if (!isWord(value, "strong") && !isWord(value, "eventual")) {
|
|
16443
|
+
this.error(`Expected strong or eventual after consistency${value ? `, not ${describe(value)}` : ""}`, this.locOf(value ?? t, line));
|
|
16444
|
+
return;
|
|
16445
|
+
}
|
|
16446
|
+
override.consistency = value.value;
|
|
16447
|
+
i += 2;
|
|
16448
|
+
} else if (isWord(t, "bandwidth") || isWord(t, "egress")) {
|
|
16449
|
+
part = t.value;
|
|
16450
|
+
const value = this.quantityOf(tokens[i + 1], part === "bandwidth" ? "MBps" : "usd/GB", t, line);
|
|
16451
|
+
if (value === void 0) return;
|
|
16452
|
+
if (part === "bandwidth") {
|
|
16453
|
+
if (value <= 0) {
|
|
16454
|
+
this.error("Bandwidth must be more than 0", this.locOf(tokens[i + 1], line));
|
|
16455
|
+
return;
|
|
16456
|
+
}
|
|
16457
|
+
override.bandwidthMBps = value;
|
|
16458
|
+
} else override.egressUsdPerGb = value;
|
|
16459
|
+
i += 2;
|
|
16460
|
+
} else {
|
|
16461
|
+
this.error(`Unexpected ${describe(t)}; expected ${CAPACITY_PARTS}`, this.locOf(t, line));
|
|
16462
|
+
return;
|
|
16463
|
+
}
|
|
16464
|
+
const clash = part === "rate" ? ["reads", "writes"].find((p) => seen.has(p)) : part === "reads" || part === "writes" ? seen.has("rate") ? part : void 0 : void 0;
|
|
16465
|
+
if (clash) {
|
|
16466
|
+
this.error(`A rate sets both reads and writes; give either a rate or reads and writes for '${id.value}'`, this.locOf(t, line));
|
|
16467
|
+
return;
|
|
16468
|
+
}
|
|
16469
|
+
if (seen.has(part)) {
|
|
16470
|
+
this.error(`${part[0].toUpperCase()}${part.slice(1)} is given twice for '${id.value}'`, this.locOf(t, line));
|
|
16471
|
+
return;
|
|
16472
|
+
}
|
|
16473
|
+
seen.add(part);
|
|
16474
|
+
}
|
|
16475
|
+
const idLoc = this.locOf(id, line);
|
|
16476
|
+
if (seen.size === 0) {
|
|
16477
|
+
this.warning(`Capacity line for '${id.value}' overrides nothing; add ${CAPACITY_PARTS}`, idLoc);
|
|
16478
|
+
return;
|
|
16479
|
+
}
|
|
16480
|
+
const first = this.capacity.find((c) => c.node === id.value);
|
|
16481
|
+
if (first) {
|
|
16482
|
+
this.error(`Duplicate capacity for '${id.value}' (first ${this.where(first.loc, idLoc)})`, idLoc);
|
|
16483
|
+
return;
|
|
16484
|
+
}
|
|
16485
|
+
this.capacity.push(override);
|
|
16486
|
+
this.deferred.push(() => {
|
|
16487
|
+
const node = this.nodes.get(id.value);
|
|
16488
|
+
if (!node) {
|
|
16489
|
+
this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
|
|
16490
|
+
return;
|
|
16491
|
+
}
|
|
16492
|
+
const storeOnly = ["shards", "consistency"].filter((p) => seen.has(p));
|
|
16493
|
+
if (storeOnly.length && !isDataStore(kindOf(node)))
|
|
16494
|
+
this.warning(`'${id.value}' is not a data store (${DATA_STORE_KINDS.join(", ")}); ${storeOnly.join(" and ")} only apply to data stores`, idLoc);
|
|
16495
|
+
});
|
|
16496
|
+
}
|
|
16497
|
+
/** `entity Url [in db] ["description"] {` */
|
|
16498
|
+
parseEntity(tokens, line) {
|
|
16499
|
+
const name = tokens[1];
|
|
16500
|
+
let i = 2;
|
|
16501
|
+
let store;
|
|
16502
|
+
if (isWord(tokens[i], "in")) {
|
|
16503
|
+
const id = tokens[i + 1];
|
|
16504
|
+
if (id?.kind !== "ident") {
|
|
16505
|
+
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));
|
|
16506
|
+
this.skipSection("entity", tokens, line);
|
|
16507
|
+
return;
|
|
16508
|
+
}
|
|
16509
|
+
store = { id: id.value, loc: this.locOf(id, line) };
|
|
16510
|
+
i += 2;
|
|
16511
|
+
}
|
|
16512
|
+
const description = tokens[i]?.kind === "string" ? tokens[i++].value : void 0;
|
|
16513
|
+
if (tokens[i]?.kind !== "lbrace") {
|
|
16514
|
+
this.error('Expected { after the entity header, e.g. entity Url in db "Short codes" {', this.locOf(tokens[i] ?? tokens[i - 1], line));
|
|
16515
|
+
this.skipSection("entity", tokens, line);
|
|
16516
|
+
return;
|
|
16517
|
+
}
|
|
16518
|
+
this.expectEnd(tokens, i + 1, line);
|
|
16519
|
+
const loc = this.locOf(name, line);
|
|
16520
|
+
const entity = { name: name.value, ...store ? { store: store.id } : {}, ...description !== void 0 ? { description } : {}, fields: [], loc };
|
|
16521
|
+
const first = this.entities.find((e) => e.name === entity.name);
|
|
16522
|
+
if (first) {
|
|
16523
|
+
this.error(`Duplicate entity '${entity.name}' (first ${this.where(first.loc, loc)})`, loc);
|
|
16524
|
+
this.skipSection("entity", tokens, line);
|
|
16525
|
+
return;
|
|
16526
|
+
}
|
|
16527
|
+
if (!this.openSection("entity", tokens, line, { entity })) return;
|
|
16528
|
+
this.entities.push(entity);
|
|
16529
|
+
if (store) {
|
|
16530
|
+
const { id, loc: storeLoc } = store;
|
|
16531
|
+
this.deferred.push(() => {
|
|
16532
|
+
const node = this.nodes.get(id);
|
|
16533
|
+
if (!node) this.warning(`Unknown node '${id}'`, storeLoc);
|
|
16534
|
+
else if (!isDataStore(kindOf(node)))
|
|
16535
|
+
this.warning(`'${id}' is not a data store (${DATA_STORE_KINDS.join(", ")}); entity '${entity.name}' is placed in a ${kindOf(node)}`, storeLoc);
|
|
16536
|
+
});
|
|
16537
|
+
}
|
|
16538
|
+
}
|
|
16539
|
+
/** `code string key`: a field name, a type and flags. */
|
|
16540
|
+
parseField(entity, tokens, line) {
|
|
16541
|
+
const [name, type] = tokens;
|
|
16542
|
+
if (name.kind !== "ident") {
|
|
16543
|
+
this.error("Expected a field: name type [key] [index] [unique] [optional], e.g. code string key", this.locOf(name, line));
|
|
16544
|
+
return;
|
|
16545
|
+
}
|
|
16546
|
+
if (type?.kind !== "ident") {
|
|
16547
|
+
this.error(`Expected a type after '${name.value}', e.g. ${name.value} string`, this.locOf(type ?? name, line));
|
|
16548
|
+
return;
|
|
16549
|
+
}
|
|
16550
|
+
const flags = [];
|
|
16551
|
+
for (const t of tokens.slice(2)) {
|
|
16552
|
+
if (t.kind !== "ident" || !FIELD_FLAGS.includes(t.value)) {
|
|
16553
|
+
this.error(`Unknown field flag ${describe(t)}; use key, index, unique or optional`, this.locOf(t, line));
|
|
16554
|
+
return;
|
|
16555
|
+
}
|
|
16556
|
+
if (flags.includes(t.value)) {
|
|
16557
|
+
this.error(`Flag '${t.value}' is given twice`, this.locOf(t, line));
|
|
16558
|
+
return;
|
|
16559
|
+
}
|
|
16560
|
+
flags.push(t.value);
|
|
16561
|
+
}
|
|
16562
|
+
if (entity.fields.some((f) => f.name === name.value)) {
|
|
16563
|
+
this.error(`Duplicate field '${name.value}' in entity '${entity.name}'`, this.locOf(name, line));
|
|
16564
|
+
return;
|
|
16565
|
+
}
|
|
16566
|
+
entity.fields.push({ name: name.value, type: type.value, flags });
|
|
16567
|
+
}
|
|
16568
|
+
/** `decision "Title" because "Reason"`, or `decision "Title" {` with because / rejected lines. */
|
|
16569
|
+
parseDecision(tokens, line) {
|
|
16570
|
+
const title = tokens[1];
|
|
16571
|
+
const decision = { title: title.value, rejected: [], loc: this.locOf(title, line) };
|
|
16572
|
+
const third = tokens[2];
|
|
16573
|
+
if (third.kind === "lbrace") {
|
|
16574
|
+
this.expectEnd(tokens, 3, line);
|
|
16575
|
+
if (this.openSection("decision", tokens, line, { decision })) this.decisions.push(decision);
|
|
16576
|
+
return;
|
|
16577
|
+
}
|
|
16578
|
+
if (!isWord(third, "because")) {
|
|
16579
|
+
this.error(`Expected because "reason" or { after the decision title, not ${describe(third)}`, this.locOf(third, line));
|
|
16580
|
+
this.skipSection("decision", tokens, line);
|
|
16581
|
+
return;
|
|
16582
|
+
}
|
|
16583
|
+
if (this.stack.length > 0) {
|
|
16584
|
+
this.error("decision is only allowed at the top level", this.locOf(tokens[0], line));
|
|
16585
|
+
return;
|
|
16586
|
+
}
|
|
16587
|
+
const reason = tokens[3];
|
|
16588
|
+
if (reason?.kind !== "string") {
|
|
16589
|
+
this.error("Expected the reason in quotes after because", this.locOf(reason ?? third, line));
|
|
16590
|
+
return;
|
|
16591
|
+
}
|
|
16592
|
+
if (!this.expectEnd(tokens, 4, line)) return;
|
|
16593
|
+
decision.because = reason.value;
|
|
16594
|
+
this.decisions.push(decision);
|
|
16595
|
+
}
|
|
16596
|
+
parseDecisionLine(decision, tokens, line) {
|
|
16597
|
+
const [word] = tokens;
|
|
16598
|
+
if (isWord(word, "because")) {
|
|
16599
|
+
if (tokens[1]?.kind !== "string") {
|
|
16600
|
+
this.error("Expected the reason in quotes after because", this.locOf(tokens[1] ?? word, line));
|
|
16601
|
+
return;
|
|
16602
|
+
}
|
|
16603
|
+
if (decision.because !== void 0) {
|
|
16604
|
+
this.error('A decision has one because; list the other options with rejected "option" "reason"', this.locOf(word, line));
|
|
16605
|
+
return;
|
|
16606
|
+
}
|
|
16607
|
+
if (this.expectEnd(tokens, 2, line)) decision.because = tokens[1].value;
|
|
16608
|
+
return;
|
|
16609
|
+
}
|
|
16610
|
+
if (isWord(word, "rejected")) {
|
|
16611
|
+
const [, option, reason] = tokens;
|
|
16612
|
+
if (option?.kind !== "string" || reason?.kind !== "string") {
|
|
16613
|
+
const bad = option?.kind !== "string" ? option : reason;
|
|
16614
|
+
this.error('Expected rejected "option" "reason"', this.locOf(bad ?? tokens[tokens.length - 1], line));
|
|
16615
|
+
return;
|
|
16616
|
+
}
|
|
16617
|
+
if (this.expectEnd(tokens, 3, line)) decision.rejected.push({ option: option.value, reason: reason.value });
|
|
16618
|
+
return;
|
|
16619
|
+
}
|
|
16620
|
+
this.error('Expected because "reason" or rejected "option" "reason"', this.locOf(word, line));
|
|
16621
|
+
}
|
|
16622
|
+
/** `test "Name" {` */
|
|
16623
|
+
parseTest(tokens, line) {
|
|
16624
|
+
const name = tokens[1];
|
|
16625
|
+
const loc = this.locOf(name, line);
|
|
16626
|
+
if (tokens[2].kind !== "lbrace") {
|
|
16627
|
+
this.error(`Expected { after the test name, not ${describe(tokens[2])}`, this.locOf(tokens[2], line));
|
|
16628
|
+
this.skipSection("test", tokens, line);
|
|
16629
|
+
return;
|
|
16630
|
+
}
|
|
16631
|
+
this.expectEnd(tokens, 3, line);
|
|
16632
|
+
const first = this.tests.find((t) => t.name === name.value);
|
|
16633
|
+
if (first) {
|
|
16634
|
+
this.error(`Duplicate test '${name.value}' (first ${this.where(first.loc, loc)})`, loc);
|
|
16635
|
+
this.skipSection("test", tokens, line);
|
|
16636
|
+
return;
|
|
16637
|
+
}
|
|
16638
|
+
const test = { name: name.value, assertions: [], loc };
|
|
16639
|
+
if (this.openSection("test", tokens, line, { test })) this.tests.push(test);
|
|
16640
|
+
}
|
|
16641
|
+
/** One assertion line of a test (docs/design/hld-and-practice.md §1.9). */
|
|
16642
|
+
parseAssertion(tokens, line) {
|
|
16643
|
+
const loc = this.lineLoc(tokens, line);
|
|
16644
|
+
const [first] = tokens;
|
|
16645
|
+
const selectors = [];
|
|
16646
|
+
const selector = (i2) => {
|
|
16647
|
+
const s = this.parseSelector(tokens, i2, line);
|
|
16648
|
+
if (s) selectors.push(s);
|
|
16649
|
+
return s;
|
|
16650
|
+
};
|
|
16651
|
+
const done = (assertion3, end) => {
|
|
16652
|
+
if (!this.expectEnd(tokens, end, line)) return void 0;
|
|
16653
|
+
selectors.forEach((s) => this.checkSelectorLater(s));
|
|
16654
|
+
return assertion3;
|
|
16655
|
+
};
|
|
16656
|
+
const expectWords = (i2, words, example) => {
|
|
16657
|
+
for (const [k, w] of words.entries()) {
|
|
16658
|
+
if (!isWord(tokens[i2 + k], w)) {
|
|
16659
|
+
this.error(`Expected '${w}' here, e.g. ${example}`, this.locOf(tokens[i2 + k] ?? tokens[tokens.length - 1], line));
|
|
16660
|
+
return false;
|
|
16661
|
+
}
|
|
16662
|
+
}
|
|
16663
|
+
return true;
|
|
16664
|
+
};
|
|
16665
|
+
if (isWord(first, "no") && isWord(tokens[1], "path")) {
|
|
16666
|
+
const example = "no path from client to any database";
|
|
16667
|
+
if (!expectWords(2, ["from"], example)) return;
|
|
16668
|
+
const from = selector(3);
|
|
16669
|
+
if (!from || !expectWords(from.next, ["to"], example)) return;
|
|
16670
|
+
const to = selector(from.next + 1);
|
|
16671
|
+
if (!to) return;
|
|
16672
|
+
return done({ kind: "noPath", from: from.selector, to: to.selector, loc }, to.next);
|
|
16673
|
+
}
|
|
16674
|
+
if (first.kind !== "string") {
|
|
16675
|
+
if (first.kind !== "ident" && first.kind !== "tech") {
|
|
16676
|
+
this.error(ASSERTION_HELP, this.locOf(first, line));
|
|
16677
|
+
return;
|
|
16678
|
+
}
|
|
16679
|
+
const inUseCase = isWord(first, "in") && tokens[1]?.kind === "string" ? { name: tokens[1].value, loc: this.locOf(tokens[1], line) } : void 0;
|
|
16680
|
+
const target = selector(inUseCase ? 2 : 0);
|
|
16681
|
+
if (!target) return;
|
|
16682
|
+
const word = tokens[target.next];
|
|
16683
|
+
if (isWord(word, "calls") || isWord(word, "never")) {
|
|
16684
|
+
const never = word.value === "never";
|
|
16685
|
+
if (never && !expectWords(target.next + 1, ["calls"], "any service never calls any storage")) return;
|
|
16686
|
+
const to = selector(target.next + (never ? 2 : 1));
|
|
16687
|
+
if (!to) return;
|
|
16688
|
+
const assertion3 = done(
|
|
16689
|
+
{ kind: "senderCalls", ...inUseCase ? { useCase: inUseCase.name } : {}, from: target.selector, to: to.selector, quantifier: never ? "never" : "some", loc },
|
|
16690
|
+
to.next
|
|
16691
|
+
);
|
|
16692
|
+
if (assertion3 && inUseCase) this.deferred.push(() => this.checkUseCase(inUseCase.name, inUseCase.loc));
|
|
16693
|
+
return assertion3;
|
|
16694
|
+
}
|
|
16695
|
+
if (inUseCase) {
|
|
16696
|
+
this.error(`Expected calls or never calls here, e.g. in "${inUseCase.name}" api calls db`, this.locOf(word ?? tokens[tokens.length - 1], line));
|
|
16697
|
+
return;
|
|
16698
|
+
}
|
|
16699
|
+
if (!isWord(word, "has")) {
|
|
16700
|
+
this.error(ASSERTION_HELP, this.locOf(word ?? first, line));
|
|
16701
|
+
return;
|
|
16702
|
+
}
|
|
16703
|
+
const i2 = target.next + 1;
|
|
16704
|
+
const example = "api has replicas >= 2";
|
|
16705
|
+
if (!expectWords(i2, ["replicas"], example) || !this.expectOp(tokens, i2 + 1, [">="], example, line)) return;
|
|
16706
|
+
const n = tokens[i2 + 2];
|
|
16707
|
+
if (n?.kind !== "number" || Number(n.value) < 1) {
|
|
16708
|
+
this.error("Expected a whole number of replicas, at least 1", this.locOf(n ?? tokens[i2 + 1], line));
|
|
16709
|
+
return;
|
|
16710
|
+
}
|
|
16711
|
+
return done({ kind: "replicas", target: target.selector, min: Number(n.value), loc }, i2 + 3);
|
|
16712
|
+
}
|
|
16713
|
+
const useCase = first.value;
|
|
16714
|
+
const useCaseLoc = this.locOf(first, line);
|
|
16715
|
+
let i = 1;
|
|
16716
|
+
let scenario;
|
|
16717
|
+
if (isWord(tokens[i], "scenario")) {
|
|
16718
|
+
const s = tokens[i + 1];
|
|
16719
|
+
if (s?.kind !== "string") {
|
|
16720
|
+
this.error("Expected a scenario name in quotes after scenario", this.locOf(s ?? tokens[i], line));
|
|
16721
|
+
return;
|
|
16722
|
+
}
|
|
16723
|
+
scenario = { name: s.value, loc: this.locOf(s, line) };
|
|
16724
|
+
i += 2;
|
|
16725
|
+
}
|
|
16726
|
+
const withScenario = scenario ? { scenario: scenario.name } : {};
|
|
16727
|
+
const verb = tokens[i];
|
|
16728
|
+
const wholeUseCase = (what) => {
|
|
16729
|
+
if (!scenario) return true;
|
|
16730
|
+
this.error(`${what} is about the whole use case; leave out scenario "\u2026"`, scenario.loc);
|
|
16731
|
+
return false;
|
|
16732
|
+
};
|
|
16733
|
+
let assertion2;
|
|
16734
|
+
if (isWord(verb, "calls")) {
|
|
16735
|
+
const target = selector(i + 1);
|
|
16736
|
+
if (!target) return;
|
|
16737
|
+
if (isWord(tokens[target.next], "before")) {
|
|
16738
|
+
const then = selector(target.next + 1);
|
|
16739
|
+
if (!then) return;
|
|
16740
|
+
assertion2 = done({ kind: "before", useCase, ...withScenario, first: target.selector, then: then.selector, loc }, then.next);
|
|
16741
|
+
} else if (isWord(tokens[target.next], "after")) {
|
|
16742
|
+
const after2 = selector(target.next + 1);
|
|
16743
|
+
if (!after2) return;
|
|
16744
|
+
assertion2 = done({ kind: "after", useCase, ...withScenario, target: target.selector, after: after2.selector, loc }, after2.next);
|
|
16745
|
+
} else {
|
|
16746
|
+
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
|
|
16747
|
+
}
|
|
16748
|
+
} else if (isWord(verb, "every")) {
|
|
16749
|
+
if (!expectWords(i + 1, ["scenario", "calls"], `"${useCase}" every scenario calls any cache`)) return;
|
|
16750
|
+
const target = selector(i + 3);
|
|
16751
|
+
if (!target) return;
|
|
16752
|
+
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "every", loc }, target.next);
|
|
16753
|
+
} else if (isWord(verb, "never") && isWord(tokens[i + 1], "waits")) {
|
|
16754
|
+
if (!expectWords(i + 2, ["for"], `"${useCase}" never waits for any queue`)) return;
|
|
16755
|
+
const target = selector(i + 3);
|
|
16756
|
+
if (!target) return;
|
|
16757
|
+
assertion2 = done({ kind: "neverWaits", useCase, ...withScenario, target: target.selector, loc }, target.next);
|
|
16758
|
+
} else if (isWord(verb, "starts")) {
|
|
16759
|
+
if (!wholeUseCase("starts at") || !expectWords(i + 1, ["at"], `"${useCase}" starts at any queue`)) return;
|
|
16760
|
+
const target = selector(i + 2);
|
|
16761
|
+
if (!target) return;
|
|
16762
|
+
assertion2 = done({ kind: "startsAt", useCase, target: target.selector, loc }, target.next);
|
|
16763
|
+
} else if (isWord(verb, "never")) {
|
|
16764
|
+
if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
|
|
16765
|
+
const target = selector(i + 2);
|
|
16766
|
+
if (!target) return;
|
|
16767
|
+
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "never", loc }, target.next);
|
|
16768
|
+
} else if (isWord(verb, "writes")) {
|
|
16769
|
+
const target = selector(i + 1);
|
|
16770
|
+
if (!target || !expectWords(target.next, ["before", "responding"], `"${useCase}" writes db before responding`)) return;
|
|
16771
|
+
assertion2 = done({ kind: "writesBeforeResponding", useCase, ...withScenario, target: target.selector, loc }, target.next + 2);
|
|
16772
|
+
} else if (isWord(verb, "responds")) {
|
|
16773
|
+
const status = tokens[i + 1];
|
|
16774
|
+
if (!status || status.kind !== "number" && status.kind !== "quantity" || !/^[1-5](\d\d|xx)$/.test(status.value)) {
|
|
16775
|
+
this.error("Expected a status code or class after responds, e.g. 201 or 4xx", this.locOf(status ?? verb, line));
|
|
16776
|
+
return;
|
|
16777
|
+
}
|
|
16778
|
+
assertion2 = done({ kind: "responds", useCase, ...withScenario, status: status.value, loc }, i + 2);
|
|
16779
|
+
} else if (isWord(verb, "has")) {
|
|
16780
|
+
if (!wholeUseCase("has scenario") || !expectWords(i + 1, ["scenario"], `"${useCase}" has scenario "Not found"`)) return;
|
|
16781
|
+
const name = tokens[i + 2];
|
|
16782
|
+
if (name?.kind !== "string") {
|
|
16783
|
+
this.error("Expected a scenario name in quotes after has scenario", this.locOf(name ?? tokens[i + 1], line));
|
|
16784
|
+
return;
|
|
16785
|
+
}
|
|
16786
|
+
assertion2 = done({ kind: "hasScenario", useCase, scenario: name.value, loc }, i + 3);
|
|
16787
|
+
} else if (isWord(verb, "handles")) {
|
|
16788
|
+
if (!wholeUseCase("handles failure") || !expectWords(i + 1, ["failure", "of"], `"${useCase}" handles failure of cache`)) return;
|
|
16789
|
+
const target = selector(i + 3);
|
|
16790
|
+
if (!target) return;
|
|
16791
|
+
assertion2 = done({ kind: "handlesFailure", useCase, target: target.selector, loc }, target.next);
|
|
16792
|
+
} else {
|
|
16793
|
+
this.error(`Expected ${ASSERTION_VERBS} after "${useCase}"`, this.locOf(verb ?? first, line));
|
|
16794
|
+
return;
|
|
16795
|
+
}
|
|
16796
|
+
if (assertion2) {
|
|
16797
|
+
this.deferred.push(() => {
|
|
16798
|
+
const found = this.checkUseCase(useCase, useCaseLoc);
|
|
16799
|
+
if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
|
|
16800
|
+
});
|
|
16801
|
+
}
|
|
16802
|
+
return assertion2;
|
|
16803
|
+
}
|
|
16804
|
+
/** A selector starting at `tokens[i]`: one or more of node id, `[Tech]`, `any <kind>`, `any strong store`, joined by `or`. */
|
|
16805
|
+
parseSelector(tokens, i, line) {
|
|
16806
|
+
const atoms = [];
|
|
16807
|
+
let next = i;
|
|
16808
|
+
for (; ; ) {
|
|
16809
|
+
const atom = this.parseSelectorAtom(tokens, next, line);
|
|
16810
|
+
if (!atom) return void 0;
|
|
16811
|
+
atoms.push(atom);
|
|
16812
|
+
next = atom.next;
|
|
16813
|
+
if (!isWord(tokens[next], "or")) break;
|
|
16814
|
+
next++;
|
|
16815
|
+
}
|
|
16816
|
+
const selector = atoms.length === 1 ? atoms[0].selector : { anyOf: atoms.map((a) => a.selector) };
|
|
16817
|
+
return { selector, next, atoms };
|
|
16818
|
+
}
|
|
16819
|
+
/** One node id, `[Tech]`, `any <kind>` or `any strong|eventual store` at `tokens[i]`. */
|
|
16820
|
+
parseSelectorAtom(tokens, i, line) {
|
|
16821
|
+
const t = tokens[i];
|
|
16822
|
+
if (t?.kind === "tech") {
|
|
16823
|
+
const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
|
|
16824
|
+
return { selector: { tech }, next: i + 1, token: t, loc: this.locOf(t, line) };
|
|
16825
|
+
}
|
|
16826
|
+
if (isWord(t, "any")) {
|
|
16827
|
+
const kind = tokens[i + 1];
|
|
16828
|
+
if (isWord(kind, "strong") || isWord(kind, "eventual")) {
|
|
16829
|
+
if (!isWord(tokens[i + 2], "store")) {
|
|
16830
|
+
this.error(`Expected 'store' here, e.g. any ${kind.value} store`, this.locOf(tokens[i + 2] ?? kind, line));
|
|
16831
|
+
return void 0;
|
|
16832
|
+
}
|
|
16833
|
+
return { selector: { consistency: kind.value }, next: i + 3, token: t, loc: this.locOf(t, line) };
|
|
16834
|
+
}
|
|
16835
|
+
if (kind?.kind !== "ident" || !isKind(kind.value)) {
|
|
16836
|
+
this.error(`Expected a kind after any: ${KINDS.join(", ")}, or strong store / eventual store`, this.locOf(kind ?? t, line));
|
|
16837
|
+
return void 0;
|
|
16838
|
+
}
|
|
16839
|
+
return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
|
|
16840
|
+
}
|
|
16841
|
+
if (t?.kind === "ident") return { selector: { node: t.value }, next: i + 1, token: t, loc: this.locOf(t, line) };
|
|
16842
|
+
this.error(`Expected a node id, [Tech] or any <kind>${t ? `, not ${describe(t)}` : ""}`, this.locOf(t ?? tokens[tokens.length - 1], line));
|
|
16843
|
+
return void 0;
|
|
16844
|
+
}
|
|
16845
|
+
/** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
|
|
16846
|
+
checkSelectorLater({ atoms }) {
|
|
16847
|
+
for (const { selector, token, loc } of atoms) {
|
|
16848
|
+
if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
|
|
16849
|
+
if ("node" in selector) {
|
|
16850
|
+
this.deferred.push(() => {
|
|
16851
|
+
if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
|
|
16852
|
+
});
|
|
16853
|
+
}
|
|
16854
|
+
}
|
|
16855
|
+
}
|
|
16856
|
+
checkUseCase(name, loc) {
|
|
16857
|
+
const useCase = this.built.find((u) => u.name === name);
|
|
16858
|
+
if (!useCase) this.warning(`Unknown use case '${name}'`, loc);
|
|
16859
|
+
return useCase;
|
|
16860
|
+
}
|
|
16861
|
+
checkScenario(useCase, name, loc) {
|
|
16862
|
+
if (useCase.scenarios.some((s) => s.name === name)) return;
|
|
16863
|
+
const names = useCase.scenarios.map((s) => `'${s.name}'`).join(", ");
|
|
16864
|
+
this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
|
|
16865
|
+
}
|
|
16866
|
+
/** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
|
|
16867
|
+
quantityOf(token, unit, after2, line) {
|
|
16868
|
+
const expected = UNIT_EXAMPLES[unit];
|
|
16869
|
+
if (token?.kind !== "quantity" && token?.kind !== "number") {
|
|
16870
|
+
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after2, line));
|
|
16871
|
+
return void 0;
|
|
16872
|
+
}
|
|
16873
|
+
const loc = this.locOf(token, line);
|
|
16874
|
+
const written = this.lines[line - 1].slice(token.col - 1, token.col - 1 + token.length);
|
|
16875
|
+
const q = parseQuantity(written);
|
|
16876
|
+
if ("error" in q) {
|
|
16877
|
+
this.error(q.error, loc);
|
|
16878
|
+
return void 0;
|
|
16879
|
+
}
|
|
16880
|
+
if (q.unit !== unit) {
|
|
16881
|
+
this.error(q.unit ? `Expected ${expected}, not '${written}'` : `'${written}' needs a unit: expected ${expected}`, loc);
|
|
16882
|
+
return void 0;
|
|
16883
|
+
}
|
|
16884
|
+
return q.value;
|
|
16885
|
+
}
|
|
16886
|
+
expectOp(tokens, i, ops, example, line) {
|
|
16887
|
+
const t = tokens[i];
|
|
16888
|
+
if (t?.kind === "op" && ops.includes(t.value)) return true;
|
|
16889
|
+
this.error(`Expected ${ops[0]} here, e.g. ${example}`, this.locOf(t ?? tokens[tokens.length - 1], line));
|
|
16890
|
+
return false;
|
|
16891
|
+
}
|
|
15881
16892
|
buildUseCase(useCase, body) {
|
|
15882
16893
|
let paths = expand(body.items);
|
|
15883
16894
|
if (paths.length > MAX_SCENARIOS) {
|
|
@@ -15920,22 +16931,22 @@ var Parser = class {
|
|
|
15920
16931
|
for (const step of raw) {
|
|
15921
16932
|
this.checkConnection(step);
|
|
15922
16933
|
if (step.arrow === "-->") {
|
|
15923
|
-
const
|
|
15924
|
-
if (!
|
|
16934
|
+
const request2 = [...steps].reverse().find((s) => s.fromServiceId === step.to && s.toServiceId === step.from && !answered.has(s) && !s.failed);
|
|
16935
|
+
if (!request2) {
|
|
15925
16936
|
this.warning(`Response has no matching request from '${step.to}' to '${step.from}'`, step.loc);
|
|
15926
16937
|
continue;
|
|
15927
16938
|
}
|
|
15928
|
-
answered.add(
|
|
15929
|
-
|
|
16939
|
+
answered.add(request2);
|
|
16940
|
+
request2.responseLoc = step.loc;
|
|
15930
16941
|
const status = step.label.match(/^(\d{3})\b\s*([\s\S]*)$/);
|
|
15931
|
-
if (status)
|
|
16942
|
+
if (status) request2.statusCode = Number(status[1]);
|
|
15932
16943
|
const payload = splitPayload(status ? status[2] : step.label);
|
|
15933
16944
|
const body = payload.body ?? payload.text;
|
|
15934
16945
|
if (body) {
|
|
15935
|
-
|
|
15936
|
-
|
|
16946
|
+
request2.responseBody = body;
|
|
16947
|
+
request2.responseFormat = payload.body ? payload.format : "FREE_TEXT";
|
|
15937
16948
|
}
|
|
15938
|
-
if (
|
|
16949
|
+
if (request2.executionType === "ASYNC_FIRE_AND_FORGET") request2.executionType = "ASYNC_REQUEST_RESPONSE";
|
|
15939
16950
|
continue;
|
|
15940
16951
|
}
|
|
15941
16952
|
const label = parseStepLabel(step.label);
|
|
@@ -15959,7 +16970,10 @@ var Parser = class {
|
|
|
15959
16970
|
isParallel: step.parallelGroup !== void 0,
|
|
15960
16971
|
isConditional: false,
|
|
15961
16972
|
...step.arrow === "-x" ? { failed: true } : {},
|
|
15962
|
-
loc: step.loc
|
|
16973
|
+
loc: step.loc,
|
|
16974
|
+
...step.multiplier !== void 0 ? { multiplier: step.multiplier } : {},
|
|
16975
|
+
...step.sizeBytes !== void 0 ? { sizeBytes: step.sizeBytes } : {},
|
|
16976
|
+
access: accessOf(label.method, step.label)
|
|
15963
16977
|
});
|
|
15964
16978
|
}
|
|
15965
16979
|
return steps;
|
|
@@ -15982,13 +16996,15 @@ var Parser = class {
|
|
|
15982
16996
|
addNode(node) {
|
|
15983
16997
|
const existing = this.nodes.get(node.id);
|
|
15984
16998
|
if (existing) {
|
|
15985
|
-
|
|
15986
|
-
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}`;
|
|
15987
|
-
this.error(`Duplicate id '${node.id}' (first declared ${where})`, node.loc);
|
|
16999
|
+
this.error(`Duplicate id '${node.id}' (first declared ${this.where(existing.loc, node.loc)})`, node.loc);
|
|
15988
17000
|
return;
|
|
15989
17001
|
}
|
|
15990
17002
|
this.nodes.set(node.id, node);
|
|
15991
17003
|
}
|
|
17004
|
+
/** `on line 3`, or `in infra.proschi on line 3` when `first` is in another file than `later`. */
|
|
17005
|
+
where(first, later) {
|
|
17006
|
+
return first.file === later.file ? `on line ${first.line}` : `in ${fileName(first.file ?? this.options.path ?? "the root document")} on line ${first.line}`;
|
|
17007
|
+
}
|
|
15992
17008
|
resolveTech(token, line) {
|
|
15993
17009
|
const resolved = techByName.get(token.value.toLowerCase());
|
|
15994
17010
|
if (resolved) return resolved;
|
|
@@ -16026,8 +17042,17 @@ var Parser = class {
|
|
|
16026
17042
|
inUseCase() {
|
|
16027
17043
|
return this.currentUseCase() !== void 0;
|
|
16028
17044
|
}
|
|
17045
|
+
/** Reports the first token from `from` on; true when there is none. */
|
|
16029
17046
|
expectEnd(tokens, from, line) {
|
|
16030
|
-
if (tokens.length
|
|
17047
|
+
if (tokens.length <= from) return true;
|
|
17048
|
+
this.error(`Unexpected ${describe(tokens[from])}`, this.locOf(tokens[from], line));
|
|
17049
|
+
return false;
|
|
17050
|
+
}
|
|
17051
|
+
/** From the first token of a line to the end of its last one. */
|
|
17052
|
+
lineLoc(tokens, line) {
|
|
17053
|
+
const first = tokens[0];
|
|
17054
|
+
const last = tokens[tokens.length - 1];
|
|
17055
|
+
return { line, col: first.col, length: last.col + last.length - first.col, ...this.fileField() };
|
|
16031
17056
|
}
|
|
16032
17057
|
locOf(token, line) {
|
|
16033
17058
|
return token ? { line, col: token.col, length: token.length, ...this.fileField() } : { line, col: 1, length: 1, ...this.fileField() };
|
|
@@ -16069,6 +17094,15 @@ function expand(items) {
|
|
|
16069
17094
|
}
|
|
16070
17095
|
return paths;
|
|
16071
17096
|
}
|
|
17097
|
+
function isWord(token, word) {
|
|
17098
|
+
return token?.kind === "ident" && token.value === word;
|
|
17099
|
+
}
|
|
17100
|
+
function sectionName(frame) {
|
|
17101
|
+
if (frame.entity) return `entity '${frame.entity.name}'`;
|
|
17102
|
+
if (frame.decision) return `decision '${frame.decision.title}'`;
|
|
17103
|
+
if (frame.test) return `test '${frame.test.name}'`;
|
|
17104
|
+
return `${frame.section} block`;
|
|
17105
|
+
}
|
|
16072
17106
|
function fileName(path) {
|
|
16073
17107
|
return path.split(/[\\/]/).pop() || path;
|
|
16074
17108
|
}
|
|
@@ -16105,6 +17139,16 @@ function parseStepLabel(input) {
|
|
|
16105
17139
|
const payload = splitPayload(input);
|
|
16106
17140
|
return { name: payload.text, format: payload.body ? payload.format : void 0, body: payload.body };
|
|
16107
17141
|
}
|
|
17142
|
+
var SIZE_UNITS = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12 };
|
|
17143
|
+
function parseSize(text) {
|
|
17144
|
+
const m = /^(\d+(?:\.\d+)?)([A-Za-z]*)$/.exec(text);
|
|
17145
|
+
if (!m) return { error: `Expected a payload size after ~, e.g. ~2MB, ~500KB or ~1.5GB${text ? `, not '~${text}'` : ""}` };
|
|
17146
|
+
const factor = SIZE_UNITS[m[2]];
|
|
17147
|
+
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` };
|
|
17148
|
+
const value = Math.round(Number(m[1]) * factor);
|
|
17149
|
+
if (value <= 0) return { error: `A payload size must be more than 0, not '~${text}'` };
|
|
17150
|
+
return { value };
|
|
17151
|
+
}
|
|
16108
17152
|
function inferProtocol(isAsync, method, target) {
|
|
16109
17153
|
if (isAsync || target?.type === "queue") return "MESSAGING";
|
|
16110
17154
|
if (!method) return "OTHER";
|
|
@@ -16154,8 +17198,9 @@ function format(source2) {
|
|
|
16154
17198
|
};
|
|
16155
17199
|
tidyBlankLines(entries).forEach((entry) => {
|
|
16156
17200
|
const last = run2[run2.length - 1];
|
|
16157
|
-
if (last && (entry.kind !== last.kind || entry.depth !== last.depth || last.kind === "connection" && last.continuation.length))
|
|
16158
|
-
|
|
17201
|
+
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))
|
|
17202
|
+
flush();
|
|
17203
|
+
if (entry.kind === "node" || entry.kind === "connection" || entry.kind === "cells") run2.push(entry);
|
|
16159
17204
|
else {
|
|
16160
17205
|
flush();
|
|
16161
17206
|
out.push(entry.kind === "blank" ? "" : indent(entry.depth) + entry.text);
|
|
@@ -16169,6 +17214,7 @@ var KEYWORDS = /* @__PURE__ */ new Set(["title", "import", "group", "usecase", "
|
|
|
16169
17214
|
function classify(lines) {
|
|
16170
17215
|
const entries = [];
|
|
16171
17216
|
let depth = 0;
|
|
17217
|
+
let section;
|
|
16172
17218
|
for (let i = 0; i < lines.length; i++) {
|
|
16173
17219
|
const raw = lines[i];
|
|
16174
17220
|
const original = raw.trim();
|
|
@@ -16184,7 +17230,13 @@ function classify(lines) {
|
|
|
16184
17230
|
const comment = commentOf(raw, tokens);
|
|
16185
17231
|
const base = { depth, original, tokens, comment };
|
|
16186
17232
|
const [first, second, third, fourth] = tokens;
|
|
16187
|
-
|
|
17233
|
+
const inSection = section !== void 0 && depth === 1 && first.kind !== "rbrace";
|
|
17234
|
+
const cells = inSection ? sectionCells(section, raw, tokens) : void 0;
|
|
17235
|
+
if (cells) {
|
|
17236
|
+
entries.push({ ...base, kind: "cells", section, cells });
|
|
17237
|
+
continue;
|
|
17238
|
+
}
|
|
17239
|
+
if (!inSection && first.kind === "ident" && second?.kind === "arrow" && third?.kind === "ident" && tokens.length <= 4 && (!fourth || fourth.kind === "label")) {
|
|
16188
17240
|
const entry = { ...base, kind: "connection", from: first.value, arrow: second.value, to: third.value, label: fourth?.value, continuation: [] };
|
|
16189
17241
|
if (fourth && bracketDepth(fourth.value) > 0) {
|
|
16190
17242
|
const delta = depth * INDENT.length - width(leadingSpace(raw));
|
|
@@ -16199,11 +17251,12 @@ function classify(lines) {
|
|
|
16199
17251
|
entries.push(entry);
|
|
16200
17252
|
continue;
|
|
16201
17253
|
}
|
|
16202
|
-
|
|
17254
|
+
const oneLineDecision = first.value === "decision" && second?.kind === "string" && third?.kind === "ident" && third.value === "because";
|
|
17255
|
+
if (!inSection && first.kind === "ident" && !KEYWORDS.has(first.value) && !oneLineDecision && tokens.slice(1).every((t) => ["string", "tech", "team", "ident", "number", "comma"].includes(t.kind))) {
|
|
16203
17256
|
let k = 1;
|
|
16204
17257
|
const take = (kind) => tokens[k]?.kind === kind ? source(raw, tokens[k++]) : "";
|
|
16205
|
-
const
|
|
16206
|
-
entries.push({ ...base, kind: "node", cells });
|
|
17258
|
+
const cells2 = [first.value, take("string"), take("tech"), take("team"), join2(raw, tokens.slice(k))];
|
|
17259
|
+
entries.push({ ...base, kind: "node", cells: cells2 });
|
|
16207
17260
|
continue;
|
|
16208
17261
|
}
|
|
16209
17262
|
const closes = first.kind === "rbrace";
|
|
@@ -16211,10 +17264,44 @@ function classify(lines) {
|
|
|
16211
17264
|
const code = join2(raw, tokens);
|
|
16212
17265
|
const text = withComment(code, comment);
|
|
16213
17266
|
entries.push({ kind: "line", depth: Math.max(0, depth - (closes ? 1 : 0)), text: sameTokens(text, tokens) ? text : original, opens, closes });
|
|
17267
|
+
if (depth === 0) section = sectionOpenedBy(tokens);
|
|
16214
17268
|
for (const t of tokens) depth = Math.max(0, depth + (t.kind === "lbrace" ? 1 : t.kind === "rbrace" ? -1 : 0));
|
|
17269
|
+
if (depth === 0) section = void 0;
|
|
16215
17270
|
}
|
|
16216
17271
|
return entries;
|
|
16217
17272
|
}
|
|
17273
|
+
function sectionOpenedBy(tokens) {
|
|
17274
|
+
const [first, second] = tokens;
|
|
17275
|
+
if (first.kind !== "ident" || tokens[tokens.length - 1].kind !== "lbrace") return void 0;
|
|
17276
|
+
switch (first.value) {
|
|
17277
|
+
case "traffic":
|
|
17278
|
+
case "requirements":
|
|
17279
|
+
case "capacity":
|
|
17280
|
+
return second?.kind === "lbrace" ? first.value : void 0;
|
|
17281
|
+
case "entity":
|
|
17282
|
+
return second?.kind === "ident" ? "entity" : void 0;
|
|
17283
|
+
case "decision":
|
|
17284
|
+
case "test":
|
|
17285
|
+
return second?.kind === "string" ? first.value : void 0;
|
|
17286
|
+
default:
|
|
17287
|
+
return void 0;
|
|
17288
|
+
}
|
|
17289
|
+
}
|
|
17290
|
+
function sectionCells(section, raw, tokens) {
|
|
17291
|
+
const [first, second] = tokens;
|
|
17292
|
+
if (tokens.some((t) => t.kind === "lbrace" || t.kind === "rbrace" || t.kind === "arrow" || t.kind === "label")) return void 0;
|
|
17293
|
+
const rest = (from) => tokens.length > from ? [join2(raw, tokens.slice(from))] : [];
|
|
17294
|
+
switch (section) {
|
|
17295
|
+
case "traffic":
|
|
17296
|
+
return first.kind === "string" && (second?.kind === "quantity" || second?.kind === "number") ? [source(raw, first), source(raw, second), ...rest(2)] : void 0;
|
|
17297
|
+
case "capacity":
|
|
17298
|
+
return first.kind === "ident" && tokens.length > 1 ? [first.value, ...rest(1)] : void 0;
|
|
17299
|
+
case "entity":
|
|
17300
|
+
return first.kind === "ident" && second?.kind === "ident" ? [first.value, second.value, ...rest(2)] : void 0;
|
|
17301
|
+
default:
|
|
17302
|
+
return void 0;
|
|
17303
|
+
}
|
|
17304
|
+
}
|
|
16218
17305
|
function tidyBlankLines(entries) {
|
|
16219
17306
|
const kept = [];
|
|
16220
17307
|
for (const entry of entries) {
|
|
@@ -16231,7 +17318,7 @@ function tidyBlankLines(entries) {
|
|
|
16231
17318
|
return kept;
|
|
16232
17319
|
}
|
|
16233
17320
|
function renderRun(run2) {
|
|
16234
|
-
const codes = run2[0].kind === "node" ? alignNodes(run2) : alignConnections(run2);
|
|
17321
|
+
const codes = run2[0].kind === "node" ? alignNodes(run2) : run2[0].kind === "cells" ? alignCells(run2) : alignConnections(run2);
|
|
16235
17322
|
const items = run2;
|
|
16236
17323
|
const commented = items.filter((e) => e.comment);
|
|
16237
17324
|
const commentCol = commented.length > 1 ? Math.max(...items.map((e, i) => e.comment ? len(codes[i]) : 0)) + 1 : 0;
|
|
@@ -16250,6 +17337,11 @@ function alignNodes(run2) {
|
|
|
16250
17337
|
return (line + cells[4]).trimEnd();
|
|
16251
17338
|
});
|
|
16252
17339
|
}
|
|
17340
|
+
function alignCells(run2) {
|
|
17341
|
+
const widths = [];
|
|
17342
|
+
for (const { cells } of run2) cells.slice(0, -1).forEach((c, i) => widths[i] = Math.max(widths[i] ?? 0, len(c)));
|
|
17343
|
+
return run2.map(({ cells }) => cells.map((c, i) => i < cells.length - 1 ? pad(c, widths[i] + 1) : c).join(""));
|
|
17344
|
+
}
|
|
16253
17345
|
function alignConnections(run2) {
|
|
16254
17346
|
const head = Math.max(...run2.map((e) => len(e.from) + 1 + e.arrow.length));
|
|
16255
17347
|
const labelled = run2.filter((e) => e.label !== void 0);
|
|
@@ -16263,8 +17355,8 @@ function alignConnections(run2) {
|
|
|
16263
17355
|
function commentOf(raw, tokens) {
|
|
16264
17356
|
const last = tokens[tokens.length - 1];
|
|
16265
17357
|
if (last.kind === "label") {
|
|
16266
|
-
const
|
|
16267
|
-
return
|
|
17358
|
+
const after2 = raw.slice(last.col);
|
|
17359
|
+
return after2.slice(stripTrailingComment(after2).length).trim();
|
|
16268
17360
|
}
|
|
16269
17361
|
return raw.slice(last.col - 1 + last.length).trim();
|
|
16270
17362
|
}
|
|
@@ -16272,7 +17364,7 @@ function join2(raw, tokens) {
|
|
|
16272
17364
|
let out = "";
|
|
16273
17365
|
for (const [i, t] of tokens.entries()) {
|
|
16274
17366
|
const text = t.kind === "label" ? ":" + (t.value ? " " + t.value : "") : source(raw, t);
|
|
16275
|
-
const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma";
|
|
17367
|
+
const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma" && t.kind === "number";
|
|
16276
17368
|
out += (i === 0 || tight ? "" : " ") + text;
|
|
16277
17369
|
}
|
|
16278
17370
|
return out;
|
|
@@ -16286,7 +17378,10 @@ function reindent(line, delta) {
|
|
|
16286
17378
|
const body = line.slice(lead.length).trimEnd();
|
|
16287
17379
|
return body ? " ".repeat(Math.max(0, width(lead) + delta)) + body : "";
|
|
16288
17380
|
}
|
|
16289
|
-
var source = (raw, t) =>
|
|
17381
|
+
var source = (raw, t) => {
|
|
17382
|
+
const text = raw.slice(t.col - 1, t.col - 1 + t.length);
|
|
17383
|
+
return t.kind === "quantity" ? text.replace(/[ \t]+/g, " ") : text;
|
|
17384
|
+
};
|
|
16290
17385
|
var withComment = (code, comment) => comment ? code + " " + comment : code;
|
|
16291
17386
|
var leadingSpace = (line) => /^[ \t]*/.exec(line)[0];
|
|
16292
17387
|
var width = (space) => [...space].reduce((n, ch) => n + (ch === " " ? INDENT.length : 1), 0);
|
|
@@ -16294,6 +17389,92 @@ var indent = (depth) => INDENT.repeat(depth);
|
|
|
16294
17389
|
var len = (s) => [...s].length;
|
|
16295
17390
|
var pad = (s, to) => s + " ".repeat(Math.max(0, to - len(s)));
|
|
16296
17391
|
|
|
17392
|
+
// ../frontend/src/dsl/sequence.ts
|
|
17393
|
+
var PAYLOAD_LIMIT = 40;
|
|
17394
|
+
function shorten(text, max = PAYLOAD_LIMIT) {
|
|
17395
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
17396
|
+
return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}\u2026` : flat;
|
|
17397
|
+
}
|
|
17398
|
+
function requestLabel(step) {
|
|
17399
|
+
const name = !step.httpMethod && step.stepName === `${step.fromServiceId} \u2192 ${step.toServiceId}` ? "" : step.stepName;
|
|
17400
|
+
return [name, step.description, step.requestBody && shorten(step.requestBody)].filter(Boolean).join(" ");
|
|
17401
|
+
}
|
|
17402
|
+
function responseLabel(step) {
|
|
17403
|
+
return [step.statusCode, step.responseBody && shorten(step.responseBody)].filter((p) => p !== void 0 && p !== "").join(" ");
|
|
17404
|
+
}
|
|
17405
|
+
function hasResponse(step) {
|
|
17406
|
+
return !step.failed && (!!step.responseLoc || step.statusCode !== void 0 || !!step.responseBody);
|
|
17407
|
+
}
|
|
17408
|
+
function request(step) {
|
|
17409
|
+
return {
|
|
17410
|
+
kind: "request",
|
|
17411
|
+
from: step.fromServiceId,
|
|
17412
|
+
to: step.toServiceId,
|
|
17413
|
+
number: step.stepOrder + 1,
|
|
17414
|
+
label: requestLabel(step),
|
|
17415
|
+
async: step.executionType !== "SYNC_REQUEST_RESPONSE",
|
|
17416
|
+
failed: !!step.failed,
|
|
17417
|
+
error: !!step.failed,
|
|
17418
|
+
step
|
|
17419
|
+
};
|
|
17420
|
+
}
|
|
17421
|
+
function response(step) {
|
|
17422
|
+
return {
|
|
17423
|
+
kind: "response",
|
|
17424
|
+
from: step.toServiceId,
|
|
17425
|
+
to: step.fromServiceId,
|
|
17426
|
+
number: step.stepOrder + 1,
|
|
17427
|
+
label: responseLabel(step),
|
|
17428
|
+
async: false,
|
|
17429
|
+
failed: false,
|
|
17430
|
+
error: (step.statusCode ?? 0) >= 400,
|
|
17431
|
+
status: step.statusCode,
|
|
17432
|
+
step
|
|
17433
|
+
};
|
|
17434
|
+
}
|
|
17435
|
+
function buildSequence(useCase, scenario, nodes = []) {
|
|
17436
|
+
const items = [];
|
|
17437
|
+
const stack = [];
|
|
17438
|
+
const emit = (message) => items.push({ kind: "message", message });
|
|
17439
|
+
const unwindFor = (from) => {
|
|
17440
|
+
const depth = stack.map((s) => s.toServiceId).lastIndexOf(from);
|
|
17441
|
+
while (stack.length > depth + 1) emit(response(stack.pop()));
|
|
17442
|
+
};
|
|
17443
|
+
const steps = scenario.steps;
|
|
17444
|
+
for (let i = 0; i < steps.length; i++) {
|
|
17445
|
+
const step = steps[i];
|
|
17446
|
+
if (step.parallelGroup === void 0) {
|
|
17447
|
+
unwindFor(step.fromServiceId);
|
|
17448
|
+
emit(request(step));
|
|
17449
|
+
if (hasResponse(step)) stack.push(step);
|
|
17450
|
+
continue;
|
|
17451
|
+
}
|
|
17452
|
+
const group = [];
|
|
17453
|
+
while (i < steps.length && steps[i].parallelGroup === step.parallelGroup) group.push(steps[i++]);
|
|
17454
|
+
i--;
|
|
17455
|
+
unwindFor(step.fromServiceId);
|
|
17456
|
+
items.push({ kind: "par", branches: group.map((s) => hasResponse(s) ? [request(s), response(s)] : [request(s)]) });
|
|
17457
|
+
}
|
|
17458
|
+
while (stack.length) emit(response(stack.pop()));
|
|
17459
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
17460
|
+
const participants = [];
|
|
17461
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17462
|
+
const add = (id) => {
|
|
17463
|
+
if (seen.has(id)) return;
|
|
17464
|
+
seen.add(id);
|
|
17465
|
+
const node = byId.get(id);
|
|
17466
|
+
participants.push({ id, name: node?.name ?? id, tech: node && !node.implicit ? node.techStack : void 0 });
|
|
17467
|
+
};
|
|
17468
|
+
for (const step of steps) {
|
|
17469
|
+
add(step.fromServiceId);
|
|
17470
|
+
add(step.toServiceId);
|
|
17471
|
+
}
|
|
17472
|
+
return { useCase, scenario, participants, items, condition: scenario.condition || void 0 };
|
|
17473
|
+
}
|
|
17474
|
+
function sequenceMessages(items) {
|
|
17475
|
+
return items.flatMap((item) => item.kind === "message" ? [item.message] : item.branches.flat());
|
|
17476
|
+
}
|
|
17477
|
+
|
|
16297
17478
|
// ../frontend/src/dsl/mermaid.ts
|
|
16298
17479
|
var RESERVED = new Set(
|
|
16299
17480
|
[
|
|
@@ -16343,6 +17524,992 @@ var RESERVED = new Set(
|
|
|
16343
17524
|
].map((w) => w.toLowerCase())
|
|
16344
17525
|
);
|
|
16345
17526
|
|
|
17527
|
+
// ../frontend/src/sim/access.ts
|
|
17528
|
+
function stepAccess(step) {
|
|
17529
|
+
return step.access ?? accessOf(step.httpMethod, step.stepName ?? "");
|
|
17530
|
+
}
|
|
17531
|
+
function accessIn(diagram) {
|
|
17532
|
+
const queues = new Set(diagram.nodes.filter((n) => kindOf(n) === "queue").map((n) => n.id));
|
|
17533
|
+
return (step) => queues.has(step.toServiceId) ? "write" : stepAccess(step);
|
|
17534
|
+
}
|
|
17535
|
+
|
|
17536
|
+
// ../frontend/src/sim/profiles.ts
|
|
17537
|
+
var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
|
|
17538
|
+
var RELATIONAL = {
|
|
17539
|
+
rps: 2e4,
|
|
17540
|
+
readRps: 2e4,
|
|
17541
|
+
writeRps: 5e3,
|
|
17542
|
+
writeScaling: "shards",
|
|
17543
|
+
latencyMs: 5,
|
|
17544
|
+
availability: 0.9995,
|
|
17545
|
+
costUsd: 400,
|
|
17546
|
+
durable: true,
|
|
17547
|
+
consistency: "strong"
|
|
17548
|
+
};
|
|
17549
|
+
var KIND_PROFILES = {
|
|
17550
|
+
client: { ...NEUTRAL, bandwidthMBps: 10 },
|
|
17551
|
+
edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
17552
|
+
cdn: { rps: 2e5, latencyMs: 5, availability: 0.9999, costUsd: 100, durable: false, egressUsdPerGb: 0.02 },
|
|
17553
|
+
loadbalancer: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
17554
|
+
gateway: { rps: 1e4, latencyMs: 10, availability: 0.9995, costUsd: 100, durable: false },
|
|
17555
|
+
// Name resolution happens before the request and is cached: off the request path.
|
|
17556
|
+
dns: { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false },
|
|
17557
|
+
service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
|
|
17558
|
+
function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
|
|
17559
|
+
cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false, consistency: "eventual" },
|
|
17560
|
+
database: RELATIONAL,
|
|
17561
|
+
search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true, consistency: "eventual" },
|
|
17562
|
+
analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true, consistency: "eventual" },
|
|
17563
|
+
queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true, consistency: "strong" },
|
|
17564
|
+
storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true, consistency: "eventual", egressUsdPerGb: 0.09 },
|
|
17565
|
+
external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
|
|
17566
|
+
other: NEUTRAL
|
|
17567
|
+
};
|
|
17568
|
+
var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true, consistency: "eventual" };
|
|
17569
|
+
var NOSQL_STRONG = { ...NOSQL, consistency: "strong" };
|
|
17570
|
+
var TECH_PROFILES = {
|
|
17571
|
+
DynamoDB: NOSQL,
|
|
17572
|
+
"AWS DynamoDB": NOSQL,
|
|
17573
|
+
Cassandra: NOSQL,
|
|
17574
|
+
CouchDB: NOSQL,
|
|
17575
|
+
"Azure Cosmos DB": NOSQL,
|
|
17576
|
+
MongoDB: NOSQL_STRONG,
|
|
17577
|
+
"GCP Bigtable": NOSQL_STRONG,
|
|
17578
|
+
"GCP Firestore": NOSQL_STRONG,
|
|
17579
|
+
"GCP Spanner": NOSQL_STRONG
|
|
17580
|
+
};
|
|
17581
|
+
var BANDWIDTH = {
|
|
17582
|
+
client: 10,
|
|
17583
|
+
edge: 1e3,
|
|
17584
|
+
cdn: 1e3,
|
|
17585
|
+
loadbalancer: 1e3,
|
|
17586
|
+
gateway: 1e3,
|
|
17587
|
+
dns: 1e3,
|
|
17588
|
+
service: 200
|
|
17589
|
+
};
|
|
17590
|
+
var DEFAULT_BANDWIDTH_MBPS = 100;
|
|
17591
|
+
function profileOf(node, override) {
|
|
17592
|
+
const kind = kindOf(node);
|
|
17593
|
+
const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
|
|
17594
|
+
const baseRead = base.readRps ?? base.rps;
|
|
17595
|
+
const baseWrite = base.writeRps ?? base.rps;
|
|
17596
|
+
const readRps = override?.readRps ?? override?.rps ?? baseRead;
|
|
17597
|
+
const writeRps = override?.writeRps ?? override?.rps ?? baseWrite;
|
|
17598
|
+
const consistency = override?.consistency ?? base.consistency;
|
|
17599
|
+
return {
|
|
17600
|
+
kind,
|
|
17601
|
+
rps: readRps,
|
|
17602
|
+
readRps,
|
|
17603
|
+
writeRps,
|
|
17604
|
+
writeScaling: base.writeScaling ?? "replicas",
|
|
17605
|
+
shards: Math.max(1, Math.floor(override?.shards ?? 1)),
|
|
17606
|
+
latencyMs: override?.latencyMs ?? base.latencyMs,
|
|
17607
|
+
availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
|
|
17608
|
+
costUsd: override?.costUsd ?? base.costUsd,
|
|
17609
|
+
durable: override?.durable ?? base.durable,
|
|
17610
|
+
...consistency && isDataStore(kind) ? { consistency } : {},
|
|
17611
|
+
bandwidthMBps: override?.bandwidthMBps ?? base.bandwidthMBps ?? BANDWIDTH[kind] ?? DEFAULT_BANDWIDTH_MBPS,
|
|
17612
|
+
egressUsdPerGb: override?.egressUsdPerGb ?? base.egressUsdPerGb ?? 0
|
|
17613
|
+
};
|
|
17614
|
+
}
|
|
17615
|
+
function capacityOf(p, replicas) {
|
|
17616
|
+
return {
|
|
17617
|
+
readRps: p.readRps * replicas * p.shards,
|
|
17618
|
+
writeRps: p.writeRps * (p.writeScaling === "shards" ? 1 : replicas) * p.shards
|
|
17619
|
+
};
|
|
17620
|
+
}
|
|
17621
|
+
function combinedUtilization(p, readUtilization, writeUtilization) {
|
|
17622
|
+
return p.writeScaling === "shards" ? Math.max(readUtilization, writeUtilization) : readUtilization + writeUtilization;
|
|
17623
|
+
}
|
|
17624
|
+
|
|
17625
|
+
// ../frontend/src/sim/flow.ts
|
|
17626
|
+
function findUseCase(diagram, name) {
|
|
17627
|
+
const lower = name.toLowerCase();
|
|
17628
|
+
return diagram.useCases.find((u) => u.name === name) ?? diagram.useCases.find((u) => u.name.toLowerCase() === lower) ?? diagram.useCases.find((u) => u.id === name);
|
|
17629
|
+
}
|
|
17630
|
+
function findScenario2(useCase, name) {
|
|
17631
|
+
const norm = (s) => s.replace(/\s*(›|>)\s*/g, " \u203A ").trim().toLowerCase();
|
|
17632
|
+
const wanted = norm(name);
|
|
17633
|
+
return useCase.scenarios.find((s) => s.name === name) ?? useCase.scenarios.find((s) => norm(s.name) === wanted || s.id === name);
|
|
17634
|
+
}
|
|
17635
|
+
function afterEntryResponse(entry, step) {
|
|
17636
|
+
const r = entry.responseLoc;
|
|
17637
|
+
if (!r || step === entry || step.loc.file !== r.file) return false;
|
|
17638
|
+
return step.loc.line > r.line || step.loc.line === r.line && step.loc.col > r.col;
|
|
17639
|
+
}
|
|
17640
|
+
function criticalPath(useCase, scenario) {
|
|
17641
|
+
const entry = scenario.steps[0];
|
|
17642
|
+
if (!entry) return [];
|
|
17643
|
+
const { items } = buildSequence(useCase, scenario);
|
|
17644
|
+
const working = /* @__PURE__ */ new Map([[entry.fromServiceId, 1]]);
|
|
17645
|
+
const opened = /* @__PURE__ */ new Set();
|
|
17646
|
+
let answered = false;
|
|
17647
|
+
const parts = [];
|
|
17648
|
+
const visit = (m, hops) => {
|
|
17649
|
+
if (m.kind === "response") {
|
|
17650
|
+
if (opened.delete(m.step)) working.set(m.step.toServiceId, (working.get(m.step.toServiceId) ?? 1) - 1);
|
|
17651
|
+
if (m.step === entry) answered = true;
|
|
17652
|
+
return;
|
|
17653
|
+
}
|
|
17654
|
+
if (afterEntryResponse(entry, m.step)) answered = true;
|
|
17655
|
+
if (answered || !working.get(m.from)) return;
|
|
17656
|
+
hops.push({ step: m.step, target: m.to, failed: m.failed, async: m.async });
|
|
17657
|
+
if (!m.async && !m.failed) {
|
|
17658
|
+
opened.add(m.step);
|
|
17659
|
+
working.set(m.to, (working.get(m.to) ?? 0) + 1);
|
|
17660
|
+
}
|
|
17661
|
+
};
|
|
17662
|
+
for (const item of items) {
|
|
17663
|
+
const hops = [];
|
|
17664
|
+
if (item.kind === "message") visit(item.message, hops);
|
|
17665
|
+
else for (const branch of item.branches) for (const m of branch) visit(m, hops);
|
|
17666
|
+
if (hops.length) parts.push(hops);
|
|
17667
|
+
}
|
|
17668
|
+
return parts;
|
|
17669
|
+
}
|
|
17670
|
+
function pathNodes(useCase, scenario) {
|
|
17671
|
+
const entry = scenario.steps[0];
|
|
17672
|
+
if (!entry) return [];
|
|
17673
|
+
const ids = [entry.fromServiceId, ...criticalPath(useCase, scenario).flatMap((part) => part.filter((h) => !h.failed).map((h) => h.target))];
|
|
17674
|
+
return [...new Set(ids)];
|
|
17675
|
+
}
|
|
17676
|
+
function requestOrder(useCase, scenario) {
|
|
17677
|
+
const entry = scenario.steps[0];
|
|
17678
|
+
const messages = sequenceMessages(buildSequence(useCase, scenario).items);
|
|
17679
|
+
const requests = [];
|
|
17680
|
+
let beforeResponse = -1;
|
|
17681
|
+
for (const m of messages) {
|
|
17682
|
+
if (m.kind === "request") {
|
|
17683
|
+
if (beforeResponse < 0 && entry && afterEntryResponse(entry, m.step)) beforeResponse = requests.length;
|
|
17684
|
+
requests.push(m);
|
|
17685
|
+
} else if (m.step === entry && beforeResponse < 0) beforeResponse = requests.length;
|
|
17686
|
+
}
|
|
17687
|
+
return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
|
|
17688
|
+
}
|
|
17689
|
+
function writesBeforeResponse(useCase, scenario, accept, isWrite = (step) => stepAccess(step) === "write") {
|
|
17690
|
+
const { requests, beforeResponse } = requestOrder(useCase, scenario);
|
|
17691
|
+
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to) && isWrite(m.step));
|
|
17692
|
+
}
|
|
17693
|
+
function callsOk(scenario, nodeId) {
|
|
17694
|
+
return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
|
|
17695
|
+
}
|
|
17696
|
+
function needs(useCase, nodeId) {
|
|
17697
|
+
return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
|
|
17698
|
+
}
|
|
17699
|
+
function fallbacksFor(useCase, nodeId) {
|
|
17700
|
+
return useCase.scenarios.filter((s) => {
|
|
17701
|
+
if (s.outcome !== "success") return false;
|
|
17702
|
+
const { requests, beforeResponse } = requestOrder(useCase, s);
|
|
17703
|
+
const early = requests.slice(0, beforeResponse).filter((m) => m.to === nodeId);
|
|
17704
|
+
const window = early.some((m) => m.failed) ? early : requests.filter((m) => m.to === nodeId);
|
|
17705
|
+
return window.some((m) => m.failed) && !window.some((m) => !m.failed && !m.async);
|
|
17706
|
+
});
|
|
17707
|
+
}
|
|
17708
|
+
function selectorText(selector) {
|
|
17709
|
+
if ("node" in selector) return selector.node;
|
|
17710
|
+
if ("tech" in selector) return `[${selector.tech}]`;
|
|
17711
|
+
if ("anyOf" in selector) return selector.anyOf.map(selectorText).join(" or ");
|
|
17712
|
+
if ("consistency" in selector) return `any ${selector.consistency} store`;
|
|
17713
|
+
return `any ${selector.kind}`;
|
|
17714
|
+
}
|
|
17715
|
+
function selectNodes(diagram, selector) {
|
|
17716
|
+
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector, diagram));
|
|
17717
|
+
}
|
|
17718
|
+
function matches(node, selector, diagram) {
|
|
17719
|
+
if (node.kind !== "component") return false;
|
|
17720
|
+
if ("node" in selector) return node.id === selector.node;
|
|
17721
|
+
if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
|
|
17722
|
+
if ("anyOf" in selector) return selector.anyOf.some((s) => matches(node, s, diagram));
|
|
17723
|
+
if ("consistency" in selector) {
|
|
17724
|
+
const override = diagram?.capacity?.find((o) => o.node === node.id);
|
|
17725
|
+
return profileOf(node, override).consistency === selector.consistency;
|
|
17726
|
+
}
|
|
17727
|
+
return kindMatches(kindOf(node), selector.kind);
|
|
17728
|
+
}
|
|
17729
|
+
function techOf(node) {
|
|
17730
|
+
return node && !node.implicit ? node.techStack : void 0;
|
|
17731
|
+
}
|
|
17732
|
+
function nodeLabel(diagram, id) {
|
|
17733
|
+
const tech = techOf(diagram.nodes.find((n) => n.id === id));
|
|
17734
|
+
return tech ? `${id} (${tech})` : id;
|
|
17735
|
+
}
|
|
17736
|
+
|
|
17737
|
+
// ../frontend/src/sim/format.ts
|
|
17738
|
+
var trim = (n, digits) => String(Number(n.toFixed(digits)));
|
|
17739
|
+
function formatRps(rps) {
|
|
17740
|
+
if (!Number.isFinite(rps)) return "\u221E rps";
|
|
17741
|
+
if (rps >= 1e6) return `${trim(rps / 1e6, 1)}m rps`;
|
|
17742
|
+
if (rps >= 1e3) return `${trim(rps / 1e3, 1)}k rps`;
|
|
17743
|
+
return `${trim(rps, rps < 10 ? 2 : 0)} rps`;
|
|
17744
|
+
}
|
|
17745
|
+
function formatMs(ms) {
|
|
17746
|
+
return `${trim(ms, ms < 100 ? 1 : 0)} ms`;
|
|
17747
|
+
}
|
|
17748
|
+
function formatAvailability(fraction) {
|
|
17749
|
+
if (fraction >= 1) return "100%";
|
|
17750
|
+
const down = (1 - fraction) * 100;
|
|
17751
|
+
const digits = Math.min(6, Math.max(1, Math.ceil(-Math.log10(down)) + 1));
|
|
17752
|
+
return `${trim(Math.floor(fraction * 100 * 10 ** digits + 1e-6) / 10 ** digits, digits)}%`;
|
|
17753
|
+
}
|
|
17754
|
+
function formatPercent(fraction) {
|
|
17755
|
+
return `${Math.round(fraction * 100)}%`;
|
|
17756
|
+
}
|
|
17757
|
+
function formatUsd(usd) {
|
|
17758
|
+
return `$${Math.round(usd).toLocaleString("en-US")}/month`;
|
|
17759
|
+
}
|
|
17760
|
+
|
|
17761
|
+
// ../frontend/src/sim/analyze.ts
|
|
17762
|
+
var DEFAULT_TIMEOUT_MS = 1e3;
|
|
17763
|
+
var HOT = 0.7;
|
|
17764
|
+
var QUEUE_CAP = 0.95;
|
|
17765
|
+
var SECONDS_PER_MONTH = 2592e3;
|
|
17766
|
+
var BYTES_PER_MB = 1e6;
|
|
17767
|
+
var BYTES_PER_GB = 1e9;
|
|
17768
|
+
var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
|
|
17769
|
+
var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
|
|
17770
|
+
var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
|
|
17771
|
+
function components(diagram) {
|
|
17772
|
+
return diagram.nodes.filter((n) => n.kind === "component");
|
|
17773
|
+
}
|
|
17774
|
+
var replicasOf = (node) => Math.max(1, node.replicas ?? 1);
|
|
17775
|
+
function resolveTraffic(diagram, warnings = []) {
|
|
17776
|
+
const result = /* @__PURE__ */ new Map();
|
|
17777
|
+
for (const u of diagram.useCases) result.set(u.id, { rps: 0, shares: u.scenarios.map((_, i) => i === 0 ? 1 : 0) });
|
|
17778
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17779
|
+
for (const entry of diagram.traffic ?? []) {
|
|
17780
|
+
const useCase = findUseCase(diagram, entry.useCase);
|
|
17781
|
+
if (!useCase) {
|
|
17782
|
+
warnings.push(`Traffic for unknown use case "${entry.useCase}" is ignored`);
|
|
17783
|
+
continue;
|
|
17784
|
+
}
|
|
17785
|
+
if (seen.has(useCase.id)) continue;
|
|
17786
|
+
seen.add(useCase.id);
|
|
17787
|
+
const shares = useCase.scenarios.map(() => 0);
|
|
17788
|
+
for (const { scenario, share } of entry.mix ?? []) {
|
|
17789
|
+
const found = findScenario2(useCase, scenario);
|
|
17790
|
+
if (found) shares[useCase.scenarios.indexOf(found)] += share;
|
|
17791
|
+
else warnings.push(`"${useCase.name}" has no scenario "${scenario}"; its traffic share is ignored`);
|
|
17792
|
+
}
|
|
17793
|
+
const total = shares.reduce((a, b) => a + b, 0);
|
|
17794
|
+
if (total <= 0) shares[0] = 1;
|
|
17795
|
+
else {
|
|
17796
|
+
if (Math.abs(total - 1) > 1e-6) warnings.push(`Traffic mix of "${useCase.name}" adds up to ${formatPercent(total)}; shares are scaled to 100%`);
|
|
17797
|
+
for (let i = 0; i < shares.length; i++) shares[i] /= total;
|
|
17798
|
+
}
|
|
17799
|
+
result.set(useCase.id, { rps: entry.rps, shares: useCase.scenarios.length ? shares : [] });
|
|
17800
|
+
}
|
|
17801
|
+
return result;
|
|
17802
|
+
}
|
|
17803
|
+
var multiplierOf = (step) => step.multiplier !== void 0 && step.multiplier > 0 ? step.multiplier : 1;
|
|
17804
|
+
function forEachStepRate(diagram, traffic, visit) {
|
|
17805
|
+
for (const u of diagram.useCases) {
|
|
17806
|
+
const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
|
|
17807
|
+
u.scenarios.forEach((s, i) => {
|
|
17808
|
+
const rate = rps * (shares[i] ?? 0);
|
|
17809
|
+
if (rate > 0) for (const step of s.steps) visit(step, rate);
|
|
17810
|
+
});
|
|
17811
|
+
}
|
|
17812
|
+
}
|
|
17813
|
+
function computeLoad(diagram, traffic) {
|
|
17814
|
+
const load = /* @__PURE__ */ new Map();
|
|
17815
|
+
const access = accessIn(diagram);
|
|
17816
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
17817
|
+
const entry = load.get(step.toServiceId) ?? { read: 0, write: 0 };
|
|
17818
|
+
entry[access(step)] += rate * multiplierOf(step);
|
|
17819
|
+
load.set(step.toServiceId, entry);
|
|
17820
|
+
});
|
|
17821
|
+
return load;
|
|
17822
|
+
}
|
|
17823
|
+
var egressSource = (step, access) => access === "read" ? step.toServiceId : step.fromServiceId;
|
|
17824
|
+
function computeEgressGb(diagram, traffic) {
|
|
17825
|
+
const gb = /* @__PURE__ */ new Map();
|
|
17826
|
+
const access = accessIn(diagram);
|
|
17827
|
+
forEachStepRate(diagram, traffic, (step, rate) => {
|
|
17828
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return;
|
|
17829
|
+
const source2 = egressSource(step, access(step));
|
|
17830
|
+
gb.set(source2, (gb.get(source2) ?? 0) + rate * multiplierOf(step) * step.sizeBytes * SECONDS_PER_MONTH / BYTES_PER_GB);
|
|
17831
|
+
});
|
|
17832
|
+
return gb;
|
|
17833
|
+
}
|
|
17834
|
+
function transferMs(step, bandwidthOf) {
|
|
17835
|
+
if (!step.sizeBytes || step.sizeBytes <= 0) return 0;
|
|
17836
|
+
const mbps = Math.min(bandwidthOf(step.fromServiceId), bandwidthOf(step.toServiceId));
|
|
17837
|
+
return mbps > 0 && Number.isFinite(mbps) ? step.sizeBytes / BYTES_PER_MB / mbps * 1e3 : 0;
|
|
17838
|
+
}
|
|
17839
|
+
var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
|
|
17840
|
+
var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
|
|
17841
|
+
var replicatedAvailability = (a, replicas) => 1 - (1 - a) ** replicas;
|
|
17842
|
+
function usageOf(p, replicas, readLoadRps, writeLoadRps) {
|
|
17843
|
+
const { readRps: readCapacityRps, writeRps: writeCapacityRps } = capacityOf(p, replicas);
|
|
17844
|
+
const readUtilization = utilizationOf(readLoadRps, readCapacityRps);
|
|
17845
|
+
const writeUtilization = utilizationOf(writeLoadRps, writeCapacityRps);
|
|
17846
|
+
const utilization = combinedUtilization(p, readUtilization, writeUtilization);
|
|
17847
|
+
const loadRps = readLoadRps + writeLoadRps;
|
|
17848
|
+
return {
|
|
17849
|
+
loadRps,
|
|
17850
|
+
capacityRps: readCapacityRps === writeCapacityRps || utilization === 0 ? readCapacityRps : loadRps / utilization,
|
|
17851
|
+
utilization,
|
|
17852
|
+
saturated: utilization >= 1,
|
|
17853
|
+
readLoadRps,
|
|
17854
|
+
writeLoadRps,
|
|
17855
|
+
readCapacityRps,
|
|
17856
|
+
writeCapacityRps,
|
|
17857
|
+
readUtilization,
|
|
17858
|
+
writeUtilization,
|
|
17859
|
+
writeScaling: p.writeScaling
|
|
17860
|
+
};
|
|
17861
|
+
}
|
|
17862
|
+
function usageText(n) {
|
|
17863
|
+
const part = (load, capacity, rho) => `${formatRps(load)} of ${formatRps(capacity)}, ${formatPercent(rho)}`;
|
|
17864
|
+
if (n.writeScaling === "shards" && n.writeLoadRps > 0) {
|
|
17865
|
+
return `reads ${part(n.readLoadRps, n.readCapacityRps, n.readUtilization)}; writes ${part(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization)}`;
|
|
17866
|
+
}
|
|
17867
|
+
return part(n.loadRps, n.capacityRps, n.utilization);
|
|
17868
|
+
}
|
|
17869
|
+
var writeBound = (n) => n.writeScaling === "shards" && n.writeUtilization >= n.readUtilization && n.writeLoadRps > 0;
|
|
17870
|
+
function useCasePercentile(scenarios, q) {
|
|
17871
|
+
const tail = 1 - q / 100 - 1e-9;
|
|
17872
|
+
const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
|
|
17873
|
+
if (heavy.length) return Math.max(...heavy.map((s) => s.value));
|
|
17874
|
+
let covered = 0;
|
|
17875
|
+
for (const s of [...scenarios].sort((a, b) => b.value - a.value)) {
|
|
17876
|
+
covered += s.share;
|
|
17877
|
+
if (covered >= tail) return s.value;
|
|
17878
|
+
}
|
|
17879
|
+
return scenarios.length ? Math.max(...scenarios.map((s) => s.value)) : 0;
|
|
17880
|
+
}
|
|
17881
|
+
function mainScenarioIndex(shares) {
|
|
17882
|
+
let best = 0;
|
|
17883
|
+
shares.forEach((share, i) => {
|
|
17884
|
+
if (share > shares[best]) best = i;
|
|
17885
|
+
});
|
|
17886
|
+
return best;
|
|
17887
|
+
}
|
|
17888
|
+
function profilesOf(diagram, warnings = []) {
|
|
17889
|
+
const nodes = components(diagram);
|
|
17890
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
17891
|
+
const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
|
|
17892
|
+
for (const o of diagram.capacity ?? []) if (!ids.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
|
|
17893
|
+
return new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
|
|
17894
|
+
}
|
|
17895
|
+
function analyze(diagram, options = {}) {
|
|
17896
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
17897
|
+
const warnings = [];
|
|
17898
|
+
const nodes = components(diagram);
|
|
17899
|
+
const profiles = profilesOf(diagram, warnings);
|
|
17900
|
+
const traffic = resolveTraffic(diagram, warnings);
|
|
17901
|
+
const load = computeLoad(diagram, traffic);
|
|
17902
|
+
const egress = computeEgressGb(diagram, traffic);
|
|
17903
|
+
const nodeResults = nodes.map((n) => {
|
|
17904
|
+
const p = profiles.get(n.id);
|
|
17905
|
+
const replicas = replicasOf(n);
|
|
17906
|
+
const { read, write } = load.get(n.id) ?? { read: 0, write: 0 };
|
|
17907
|
+
const usage = usageOf(p, replicas, read, write);
|
|
17908
|
+
const egressGbPerMonth = egress.get(n.id) ?? 0;
|
|
17909
|
+
const egressUsd = egressGbPerMonth * p.egressUsdPerGb;
|
|
17910
|
+
return {
|
|
17911
|
+
id: n.id,
|
|
17912
|
+
kind: p.kind,
|
|
17913
|
+
replicas,
|
|
17914
|
+
shards: p.shards,
|
|
17915
|
+
...usage,
|
|
17916
|
+
...p.consistency ? { consistency: p.consistency } : {},
|
|
17917
|
+
latencyMs: hopLatency(p.latencyMs, usage.utilization),
|
|
17918
|
+
availability: replicatedAvailability(p.availability, replicas),
|
|
17919
|
+
costUsd: replicas * p.shards * p.costUsd + egressUsd,
|
|
17920
|
+
egressGbPerMonth,
|
|
17921
|
+
egressUsd,
|
|
17922
|
+
durable: p.durable
|
|
17923
|
+
};
|
|
17924
|
+
});
|
|
17925
|
+
const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
|
|
17926
|
+
const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
|
|
17927
|
+
const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
|
|
17928
|
+
const bandwidthOf = (id) => profiles.get(id)?.bandwidthMBps ?? Infinity;
|
|
17929
|
+
for (const n of nodeResults) {
|
|
17930
|
+
const usage = usageText(n);
|
|
17931
|
+
if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. ${writeBound(n) ? "Add shards for writes" : "Add replicas"} or take load off it.`);
|
|
17932
|
+
else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
|
|
17933
|
+
}
|
|
17934
|
+
const useCases = diagram.useCases.map((u) => {
|
|
17935
|
+
const { rps, shares } = traffic.get(u.id);
|
|
17936
|
+
const scenarios = u.scenarios.map((s, i) => {
|
|
17937
|
+
const meanMs = criticalPath(u, s).reduce(
|
|
17938
|
+
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target) + transferMs(hop.step, bandwidthOf))),
|
|
17939
|
+
0
|
|
17940
|
+
);
|
|
17941
|
+
return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
|
|
17942
|
+
});
|
|
17943
|
+
const percentiles = Object.fromEntries(
|
|
17944
|
+
PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], useCasePercentile(scenarios.map((s) => ({ share: s.share, value: s.percentiles[PERCENTILE_KEYS[q]] })), q)])
|
|
17945
|
+
);
|
|
17946
|
+
if (rps > 0 && u.scenarios.length === 0) warnings.push(`"${u.name}" has traffic but no steps`);
|
|
17947
|
+
return { id: u.id, name: u.name, rps, scenarios, percentiles, availability: useCaseAvailability(u, shares, availabilityOf) };
|
|
17948
|
+
});
|
|
17949
|
+
return {
|
|
17950
|
+
nodes: nodeResults,
|
|
17951
|
+
useCases,
|
|
17952
|
+
totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
|
|
17953
|
+
totalEgressUsd: nodeResults.reduce((sum, n) => sum + n.egressUsd, 0),
|
|
17954
|
+
singlePointsOfFailure: singlePointsOfFailure(diagram),
|
|
17955
|
+
warnings
|
|
17956
|
+
};
|
|
17957
|
+
}
|
|
17958
|
+
function percentilesOf(meanMs) {
|
|
17959
|
+
return Object.fromEntries(PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], meanMs * PERCENTILE_FACTORS[q]]));
|
|
17960
|
+
}
|
|
17961
|
+
function useCaseAvailability(useCase, shares, availabilityOf) {
|
|
17962
|
+
if (useCase.scenarios.length === 0) return 1;
|
|
17963
|
+
const main = pathNodes(useCase, useCase.scenarios[mainScenarioIndex(shares)]);
|
|
17964
|
+
const onMain = new Set(main);
|
|
17965
|
+
let result = 1;
|
|
17966
|
+
for (const id of main) {
|
|
17967
|
+
const a = availabilityOf(id);
|
|
17968
|
+
const fallback = Math.max(
|
|
17969
|
+
0,
|
|
17970
|
+
...fallbacksFor(useCase, id).map(
|
|
17971
|
+
(s) => pathNodes(useCase, s).filter((other) => other !== id && !onMain.has(other)).reduce((product, other) => product * availabilityOf(other), 1)
|
|
17972
|
+
)
|
|
17973
|
+
);
|
|
17974
|
+
result *= 1 - (1 - a) * (1 - fallback);
|
|
17975
|
+
}
|
|
17976
|
+
return result;
|
|
17977
|
+
}
|
|
17978
|
+
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external" && kind !== "dns";
|
|
17979
|
+
function singlePointsOfFailure(diagram) {
|
|
17980
|
+
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);
|
|
17981
|
+
}
|
|
17982
|
+
|
|
17983
|
+
// ../frontend/src/sim/tests.ts
|
|
17984
|
+
var pass = (message) => ({ passed: true, message });
|
|
17985
|
+
var fail = (message, hint) => ({ passed: false, message, hint });
|
|
17986
|
+
function runTests(diagram, analysis = analyze(diagram)) {
|
|
17987
|
+
const access = accessIn(diagram);
|
|
17988
|
+
const run2 = {
|
|
17989
|
+
diagram,
|
|
17990
|
+
analysis,
|
|
17991
|
+
nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
|
|
17992
|
+
useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
|
|
17993
|
+
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares])),
|
|
17994
|
+
profiles: profilesOf(diagram),
|
|
17995
|
+
isWrite: (step) => access(step) === "write"
|
|
17996
|
+
};
|
|
17997
|
+
const results = (diagram.requirements ?? []).map((r, i) => {
|
|
17998
|
+
const { passed, message, hint } = requirement(run2, r);
|
|
17999
|
+
return { id: `req:${i + 1}`, name: requirementName(r), category: CATEGORY[r.kind], passed, message, hint, loc: r.loc };
|
|
18000
|
+
});
|
|
18001
|
+
const names = /* @__PURE__ */ new Map();
|
|
18002
|
+
for (const test of diagram.tests ?? []) {
|
|
18003
|
+
const n = (names.get(test.name) ?? 0) + 1;
|
|
18004
|
+
names.set(test.name, n);
|
|
18005
|
+
results.push({ id: n === 1 ? `test:${test.name}` : `test:${test.name}#${n}`, name: test.name, category: "flow", ...flowTest(run2, test), loc: test.loc });
|
|
18006
|
+
}
|
|
18007
|
+
return results.map(({ hint, ...r }) => r.passed || !hint ? r : { ...r, hint });
|
|
18008
|
+
}
|
|
18009
|
+
var CATEGORY = {
|
|
18010
|
+
latency: "latency",
|
|
18011
|
+
availability: "availability",
|
|
18012
|
+
durable: "durability",
|
|
18013
|
+
survive: "resilience",
|
|
18014
|
+
cost: "cost"
|
|
18015
|
+
};
|
|
18016
|
+
var percentileName = (q) => PERCENTILE_KEYS[q];
|
|
18017
|
+
var lineOf = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
|
|
18018
|
+
function stepText(step) {
|
|
18019
|
+
const arrow = step.failed ? "-x" : step.executionType === "SYNC_REQUEST_RESPONSE" ? "->" : "->>";
|
|
18020
|
+
const label = requestLabel(step);
|
|
18021
|
+
return `${step.fromServiceId} ${arrow} ${step.toServiceId}${label ? ` : ${label}` : ""} at ${lineOf(step.loc)}`;
|
|
18022
|
+
}
|
|
18023
|
+
function requirementName(r) {
|
|
18024
|
+
switch (r.kind) {
|
|
18025
|
+
case "latency":
|
|
18026
|
+
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"}${r.scenario !== void 0 ? ` scenario ${r.scenario}` : ""} < ${formatMs(r.maxMs)}`;
|
|
18027
|
+
case "availability":
|
|
18028
|
+
return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
|
|
18029
|
+
case "durable":
|
|
18030
|
+
return `${r.useCase} is durable`;
|
|
18031
|
+
case "survive":
|
|
18032
|
+
return r.target === "any" ? "survive any node failure" : `survive failure of ${selectorText(r.target)}`;
|
|
18033
|
+
case "cost":
|
|
18034
|
+
return `cost \u2264 ${formatUsd(r.maxUsdPerMonth)}`;
|
|
18035
|
+
}
|
|
18036
|
+
}
|
|
18037
|
+
function requirement(run2, r) {
|
|
18038
|
+
switch (r.kind) {
|
|
18039
|
+
case "latency":
|
|
18040
|
+
if (r.useCase !== void 0 && r.scenario !== void 0) return scenarioLatency(run2, r.useCase, r.scenario, r.percentile, r.maxMs);
|
|
18041
|
+
return overUseCases(run2, r.useCase, (u) => latency(run2, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
|
|
18042
|
+
case "availability":
|
|
18043
|
+
return overUseCases(run2, r.useCase, (u) => availability(run2, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
|
|
18044
|
+
case "durable":
|
|
18045
|
+
return durable(run2, r.useCase);
|
|
18046
|
+
case "survive":
|
|
18047
|
+
return survive(run2, r.target);
|
|
18048
|
+
case "cost":
|
|
18049
|
+
return cost(run2, r.maxUsdPerMonth);
|
|
18050
|
+
}
|
|
18051
|
+
}
|
|
18052
|
+
function overUseCases(run2, name, check, limit, anyWithoutTraffic = false) {
|
|
18053
|
+
if (name !== void 0) {
|
|
18054
|
+
const u = findUseCase(run2.diagram, name);
|
|
18055
|
+
return u ? check(u) : unknownUseCase(run2, name);
|
|
18056
|
+
}
|
|
18057
|
+
let targets = run2.diagram.useCases.filter((u) => (run2.useCases.get(u.id)?.rps ?? 0) > 0);
|
|
18058
|
+
if (targets.length === 0 && anyWithoutTraffic) targets = run2.diagram.useCases;
|
|
18059
|
+
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');
|
|
18060
|
+
const checks = targets.map(check);
|
|
18061
|
+
const failed = checks.filter((c) => !c.passed);
|
|
18062
|
+
if (failed.length === 0) {
|
|
18063
|
+
return pass(checks.length === 1 ? checks[0].message : `All ${checks.length} use cases hold; ${checks.map((c) => c.value).join(", ")} ${limit}`);
|
|
18064
|
+
}
|
|
18065
|
+
return fail(failed.length === 1 ? failed[0].message : `${failed.map((c) => c.value ?? c.message).join("; ")} ${limit}`, failed[0].hint);
|
|
18066
|
+
}
|
|
18067
|
+
function unknownUseCase(run2, name) {
|
|
18068
|
+
const known = run2.diagram.useCases.map((u) => `"${u.name}"`);
|
|
18069
|
+
return { ...fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block'), missing: `use case ${name}` };
|
|
18070
|
+
}
|
|
18071
|
+
function saturatedOn(run2, ids, value, maxMs) {
|
|
18072
|
+
const n = [...new Set(ids)].map((id) => run2.nodes.get(id)).find((x) => !!x?.saturated);
|
|
18073
|
+
if (!n) return void 0;
|
|
18074
|
+
const v = value(n);
|
|
18075
|
+
return { ...fail(`${v} (limit ${formatMs(maxMs)})`, saturationHint(run2, n)), value: v };
|
|
18076
|
+
}
|
|
18077
|
+
function latency(run2, u, q, maxMs) {
|
|
18078
|
+
const result = run2.useCases.get(u.id);
|
|
18079
|
+
const key = percentileName(q);
|
|
18080
|
+
const shares = run2.shares.get(u.id) ?? [];
|
|
18081
|
+
const touched = u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []);
|
|
18082
|
+
const saturated = saturatedOn(run2, touched, (n) => `${key} of ${u.name}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
18083
|
+
if (saturated) return saturated;
|
|
18084
|
+
const ms = result.percentiles[key];
|
|
18085
|
+
const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
|
|
18086
|
+
const message = `${value} (limit ${formatMs(maxMs)})`;
|
|
18087
|
+
if (ms < maxMs) return { ...pass(message), value };
|
|
18088
|
+
const index = Math.max(0, result.scenarios.findIndex((s) => s.percentiles[key] === ms));
|
|
18089
|
+
const lead = u.scenarios.length > 1 ? `The "${u.scenarios[index].name}" path (${formatPercent(result.scenarios[index].share)} of traffic) sets ${key}. ` : "";
|
|
18090
|
+
return { ...fail(message, lead + latencyHint(run2, u, u.scenarios[index])), value };
|
|
18091
|
+
}
|
|
18092
|
+
function scenarioLatency(run2, useCase, scenario, q, maxMs) {
|
|
18093
|
+
const sc = scope(run2, useCase, scenario);
|
|
18094
|
+
if (isCheck(sc)) return sc;
|
|
18095
|
+
const s = sc.scenarios[0];
|
|
18096
|
+
const key = percentileName(q);
|
|
18097
|
+
const what = `${key} of "${sc.u.name}" scenario "${s.name}"`;
|
|
18098
|
+
const saturated = saturatedOn(run2, s.steps.map((step) => step.toServiceId), (n) => `${what}: ${n.id} is saturated (${usageText(n)})`, maxMs);
|
|
18099
|
+
if (saturated) return saturated;
|
|
18100
|
+
const result = run2.useCases.get(sc.u.id)?.scenarios[sc.u.scenarios.indexOf(s)];
|
|
18101
|
+
const ms = result?.percentiles[key] ?? 0;
|
|
18102
|
+
const message = `${what} is ${formatMs(ms)} (limit ${formatMs(maxMs)})`;
|
|
18103
|
+
return ms < maxMs ? pass(message) : fail(message, latencyHint(run2, sc.u, s));
|
|
18104
|
+
}
|
|
18105
|
+
function saturationHint(run2, n) {
|
|
18106
|
+
const label = nodeLabel(run2.diagram, n.id);
|
|
18107
|
+
if (writeBound(n)) {
|
|
18108
|
+
const perShard = n.writeCapacityRps / n.shards;
|
|
18109
|
+
const shards = Math.ceil(n.writeLoadRps / (perShard * HOT));
|
|
18110
|
+
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`;
|
|
18111
|
+
}
|
|
18112
|
+
const rho = n.writeScaling === "shards" ? n.readUtilization : n.utilization;
|
|
18113
|
+
const replicas = Math.ceil(rho * n.replicas / HOT);
|
|
18114
|
+
return `Add replicas to ${label}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
|
|
18115
|
+
}
|
|
18116
|
+
function latencyHint(run2, u, scenario) {
|
|
18117
|
+
const hops = criticalPath(u, scenario).flat();
|
|
18118
|
+
const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run2.nodes.get(h.target)?.latencyMs ?? 0;
|
|
18119
|
+
const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
|
|
18120
|
+
if (!slowest) return "Shorten the synchronous path of the request.";
|
|
18121
|
+
const label = nodeLabel(run2.diagram, slowest.target);
|
|
18122
|
+
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.`;
|
|
18123
|
+
const sized = hops.find((h) => (h.step.sizeBytes ?? 0) > 0);
|
|
18124
|
+
const node = run2.nodes.get(slowest.target);
|
|
18125
|
+
if (node && node.utilization > HOT) return `${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
|
|
18126
|
+
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.` : "";
|
|
18127
|
+
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}`;
|
|
18128
|
+
}
|
|
18129
|
+
function availability(run2, u, minPercent) {
|
|
18130
|
+
const result = run2.useCases.get(u.id);
|
|
18131
|
+
const value = `availability of ${u.name} is ${formatAvailability(result.availability)}`;
|
|
18132
|
+
const message = `${value} (limit ${minPercent}%)`;
|
|
18133
|
+
if (result.availability * 100 >= minPercent - 1e-9) return { ...pass(message), value };
|
|
18134
|
+
const shares = run2.shares.get(u.id) ?? [];
|
|
18135
|
+
const main = u.scenarios.length ? pathNodes(u, u.scenarios[mainScenarioIndex(shares)]) : [];
|
|
18136
|
+
const weakest = main.filter((id) => fallbacksFor(u, id).length === 0).map((id) => run2.nodes.get(id)).filter((n) => !!n && n.availability < 1).sort((a, b) => a.availability - b.availability)[0];
|
|
18137
|
+
const hint = weakest ? `The weakest link is ${nodeLabel(run2.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";
|
|
18138
|
+
return { ...fail(message, hint), value };
|
|
18139
|
+
}
|
|
18140
|
+
function writeGap(run2, u, s, accept) {
|
|
18141
|
+
const { requests, beforeResponse } = requestOrder(u, s);
|
|
18142
|
+
const hits = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
|
|
18143
|
+
const writes = hits.filter(({ m }) => run2.isWrite(m.step));
|
|
18144
|
+
const early = writes.find(({ m, i }) => m.async && i < beforeResponse);
|
|
18145
|
+
if (early) return { kind: "async", step: early.m.step };
|
|
18146
|
+
const late = writes.find(({ i }) => i >= beforeResponse);
|
|
18147
|
+
if (late) return { kind: "after", step: late.m.step };
|
|
18148
|
+
const read = hits.find(({ m, i }) => i < beforeResponse && !m.async);
|
|
18149
|
+
if (read) return { kind: "read", step: read.m.step };
|
|
18150
|
+
return { kind: "none" };
|
|
18151
|
+
}
|
|
18152
|
+
function writesCheck(run2, u, scenarios, accept, what) {
|
|
18153
|
+
const success = scenarios.filter((s) => s.outcome === "success");
|
|
18154
|
+
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");
|
|
18155
|
+
const gaps = success.filter((s) => !writesBeforeResponse(u, s, accept, run2.isWrite));
|
|
18156
|
+
if (gaps.length === 0) {
|
|
18157
|
+
const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
|
|
18158
|
+
return pass(`${label} writes to ${what} before responding`);
|
|
18159
|
+
}
|
|
18160
|
+
const details = gaps.map((s) => ({ s, gap: writeGap(run2, u, s, accept) }));
|
|
18161
|
+
const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
|
|
18162
|
+
const text = (s, gap) => {
|
|
18163
|
+
switch (gap.kind) {
|
|
18164
|
+
case "async":
|
|
18165
|
+
return `${where(s)} writes to ${what} only asynchronously: ${stepText(gap.step)}`;
|
|
18166
|
+
case "after":
|
|
18167
|
+
return `${where(s)} writes to ${what} only after responding: ${stepText(gap.step)}`;
|
|
18168
|
+
case "read":
|
|
18169
|
+
return `${where(s)} only reads from ${what} before responding: ${stepText(gap.step)} is a read`;
|
|
18170
|
+
case "none":
|
|
18171
|
+
return `${where(s)} never writes to ${what} before responding`;
|
|
18172
|
+
}
|
|
18173
|
+
};
|
|
18174
|
+
const message = details.map(({ s, gap }) => text(s, gap)).join("; ");
|
|
18175
|
+
const hints = {
|
|
18176
|
+
async: "Make the write a synchronous request (->) so it is acknowledged before the response",
|
|
18177
|
+
after: "Move the write before the step that answers the entry request",
|
|
18178
|
+
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",
|
|
18179
|
+
none: `Add a synchronous write (->) to ${what} before the entry request is answered`
|
|
18180
|
+
};
|
|
18181
|
+
return fail(message, hints[details[0].gap.kind]);
|
|
18182
|
+
}
|
|
18183
|
+
function durable(run2, name) {
|
|
18184
|
+
const u = findUseCase(run2.diagram, name);
|
|
18185
|
+
if (!u) return unknownUseCase(run2, name);
|
|
18186
|
+
return writesCheck(run2, u, u.scenarios, (id) => !!run2.nodes.get(id)?.durable, "a durable store");
|
|
18187
|
+
}
|
|
18188
|
+
function survive(run2, target) {
|
|
18189
|
+
const all = components(run2.diagram);
|
|
18190
|
+
const selected = target === "any" ? all.filter((n) => DEPLOYED(run2.nodes.get(n.id).kind)) : selectNodes(run2.diagram, target);
|
|
18191
|
+
const what = target === "any" ? "any one node" : selectorText(target);
|
|
18192
|
+
if (selected.length === 0) {
|
|
18193
|
+
return target === "any" ? pass("No nodes to lose") : fail(`No node matches ${what}`, "Use a node id, [Tech] or any <kind>");
|
|
18194
|
+
}
|
|
18195
|
+
const failures = [];
|
|
18196
|
+
for (const node of selected) {
|
|
18197
|
+
const n = run2.nodes.get(node.id);
|
|
18198
|
+
const label = nodeLabel(run2.diagram, node.id);
|
|
18199
|
+
const replicas = replicasOf(node);
|
|
18200
|
+
if (replicas >= 2) {
|
|
18201
|
+
const left = usageOf(run2.profiles.get(node.id), replicas - 1, n.readLoadRps, n.writeLoadRps);
|
|
18202
|
+
if (left.saturated) {
|
|
18203
|
+
failures.push(
|
|
18204
|
+
fail(
|
|
18205
|
+
`Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left.capacityRps)} for ${formatRps(n.loadRps)} (${formatPercent(left.utilization)})`,
|
|
18206
|
+
writeBound(left) ? saturationHint(run2, { ...n, ...left }) : `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
|
|
18207
|
+
)
|
|
18208
|
+
);
|
|
18209
|
+
}
|
|
18210
|
+
continue;
|
|
18211
|
+
}
|
|
18212
|
+
for (const u of run2.diagram.useCases) {
|
|
18213
|
+
if (needs(u, node.id) && fallbacksFor(u, node.id).length === 0) {
|
|
18214
|
+
failures.push(
|
|
18215
|
+
fail(
|
|
18216
|
+
`Losing ${label} breaks "${u.name}"`,
|
|
18217
|
+
`Add a replica (x2 on ${node.id}) or a fallback scenario in "${u.name}" that calls ${node.id} with -x and still completes`
|
|
18218
|
+
)
|
|
18219
|
+
);
|
|
18220
|
+
}
|
|
18221
|
+
}
|
|
18222
|
+
}
|
|
18223
|
+
if (failures.length === 0) {
|
|
18224
|
+
const count = selected.length === 1 ? selected[0].id : `any of ${selected.length} nodes`;
|
|
18225
|
+
return pass(`Every use case keeps working after losing ${count}`);
|
|
18226
|
+
}
|
|
18227
|
+
const more = failures.length > 1 ? ` (and ${failures.length - 1} more)` : "";
|
|
18228
|
+
return fail(`${failures[0].message}${more}`, failures[0].hint);
|
|
18229
|
+
}
|
|
18230
|
+
function cost(run2, max) {
|
|
18231
|
+
const { totalCostUsd: total, totalEgressUsd: egress } = run2.analysis;
|
|
18232
|
+
const ofWhich = egress > 0 ? `, ${formatUsd(egress)} of it egress` : "";
|
|
18233
|
+
const message = `Total cost is ${formatUsd(total)}${ofWhich} (limit ${formatUsd(max)})`;
|
|
18234
|
+
if (total <= max) return pass(message);
|
|
18235
|
+
const usd = (x) => formatUsd(x).replace("/month", "");
|
|
18236
|
+
const top = [...run2.analysis.nodes].filter((n) => n.costUsd > 0).sort((a, b) => b.costUsd - a.costUsd).slice(0, 3).map((n) => {
|
|
18237
|
+
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);
|
|
18238
|
+
return `${n.id} ${formatUsd(n.costUsd)}${notes.length ? ` (${notes.join(", ")})` : ""}`;
|
|
18239
|
+
});
|
|
18240
|
+
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." : "";
|
|
18241
|
+
return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.${egressHint}`);
|
|
18242
|
+
}
|
|
18243
|
+
function flowTest(run2, test) {
|
|
18244
|
+
const checks = test.assertions.map((a) => ({ ...assertion(run2, a), loc: a.loc }));
|
|
18245
|
+
const assertions = checks.map(({ passed, message, hint, loc }) => hint === void 0 ? { passed, message, loc } : { passed, message, hint, loc });
|
|
18246
|
+
if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
|
|
18247
|
+
const failed = checks.filter((a) => !a.passed);
|
|
18248
|
+
if (failed.length === 0) {
|
|
18249
|
+
return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
|
|
18250
|
+
}
|
|
18251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18252
|
+
const reported = failed.filter((a) => {
|
|
18253
|
+
const key = a.missing ?? a.message;
|
|
18254
|
+
if (seen.has(key)) return false;
|
|
18255
|
+
seen.add(key);
|
|
18256
|
+
return true;
|
|
18257
|
+
});
|
|
18258
|
+
return { passed: false, message: reported.map((a) => a.message).join("; "), hint: reported[0].hint, assertions };
|
|
18259
|
+
}
|
|
18260
|
+
var quote = (s) => `"${s}"`;
|
|
18261
|
+
function scope(run2, useCase, scenario) {
|
|
18262
|
+
const u = findUseCase(run2.diagram, useCase);
|
|
18263
|
+
if (!u) return unknownUseCase(run2, useCase);
|
|
18264
|
+
if (scenario === void 0) return { u, scenarios: u.scenarios, label: quote(u.name) };
|
|
18265
|
+
const s = findScenario2(u, scenario);
|
|
18266
|
+
if (!s) return missingScenario(u, scenario);
|
|
18267
|
+
return { u, scenarios: [s], label: `${quote(u.name)} scenario ${quote(s.name)}` };
|
|
18268
|
+
}
|
|
18269
|
+
function missingScenario(u, scenario) {
|
|
18270
|
+
const names = u.scenarios.map((s) => quote(s.name)).join(", ");
|
|
18271
|
+
return {
|
|
18272
|
+
...fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`),
|
|
18273
|
+
missing: `scenario ${u.name} \u203A ${scenario}`
|
|
18274
|
+
};
|
|
18275
|
+
}
|
|
18276
|
+
var isCheck = (x) => "passed" in x;
|
|
18277
|
+
var idsOf = (run2, selector) => new Set(selectNodes(run2.diagram, selector).map((n) => n.id));
|
|
18278
|
+
function assertion(run2, a) {
|
|
18279
|
+
switch (a.kind) {
|
|
18280
|
+
case "calls":
|
|
18281
|
+
return calls(run2, a);
|
|
18282
|
+
case "before":
|
|
18283
|
+
return before(run2, a);
|
|
18284
|
+
case "writesBeforeResponding": {
|
|
18285
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18286
|
+
if (isCheck(sc)) return sc;
|
|
18287
|
+
const ids = idsOf(run2, a.target);
|
|
18288
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
18289
|
+
return writesCheck(run2, sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
18290
|
+
}
|
|
18291
|
+
case "responds":
|
|
18292
|
+
return responds(run2, a);
|
|
18293
|
+
case "hasScenario": {
|
|
18294
|
+
const u = findUseCase(run2.diagram, a.useCase);
|
|
18295
|
+
if (!u) return unknownUseCase(run2, a.useCase);
|
|
18296
|
+
const s = findScenario2(u, a.scenario);
|
|
18297
|
+
return s ? pass(`${quote(u.name)} has scenario ${quote(s.name)}`) : missingScenario(u, a.scenario);
|
|
18298
|
+
}
|
|
18299
|
+
case "handlesFailure": {
|
|
18300
|
+
const u = findUseCase(run2.diagram, a.useCase);
|
|
18301
|
+
if (!u) return unknownUseCase(run2, a.useCase);
|
|
18302
|
+
const ids = idsOf(run2, a.target);
|
|
18303
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
18304
|
+
const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
|
|
18305
|
+
const what = selectorText(a.target);
|
|
18306
|
+
return handled ? pass(`${quote(u.name)} handles a failed call to ${what} in ${quote(handled.name)}`) : fail(
|
|
18307
|
+
`No success scenario of ${quote(u.name)} has a failed call to ${what}`,
|
|
18308
|
+
`Add alt "${what} down" { \u2026 -x ${[...ids][0]} \u2026 } that still answers the entry request with a 2xx`
|
|
18309
|
+
);
|
|
18310
|
+
}
|
|
18311
|
+
case "noPath":
|
|
18312
|
+
return noPath(run2, a.from, a.to);
|
|
18313
|
+
case "replicas": {
|
|
18314
|
+
const nodes = selectNodes(run2.diagram, a.target);
|
|
18315
|
+
if (nodes.length === 0) return noMatch(a.target);
|
|
18316
|
+
const short = nodes.filter((n) => replicasOf(n) < a.min);
|
|
18317
|
+
const has = (n) => `${n.id} has ${replicasOf(n)} replica${replicasOf(n) === 1 ? "" : "s"}`;
|
|
18318
|
+
if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
|
|
18319
|
+
return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
|
|
18320
|
+
}
|
|
18321
|
+
case "neverWaits":
|
|
18322
|
+
return neverWaits(run2, a);
|
|
18323
|
+
case "after":
|
|
18324
|
+
return after(run2, a);
|
|
18325
|
+
case "senderCalls":
|
|
18326
|
+
return senderCalls(run2, a);
|
|
18327
|
+
case "startsAt":
|
|
18328
|
+
return startsAt(run2, a);
|
|
18329
|
+
}
|
|
18330
|
+
}
|
|
18331
|
+
function noMatch(selector) {
|
|
18332
|
+
return fail(`No node matches ${selectorText(selector)}`, "Use a node id, [Tech] or any <kind> (any cache, any database, \u2026)");
|
|
18333
|
+
}
|
|
18334
|
+
function calls(run2, a) {
|
|
18335
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18336
|
+
if (isCheck(sc)) return sc;
|
|
18337
|
+
const what = selectorText(a.target);
|
|
18338
|
+
const ids = idsOf(run2, a.target);
|
|
18339
|
+
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId));
|
|
18340
|
+
const calling = sc.scenarios.filter((s) => called(s).length > 0);
|
|
18341
|
+
const names = (list) => list.map((s) => quote(s.name)).join(", ");
|
|
18342
|
+
const multi = sc.scenarios.length > 1;
|
|
18343
|
+
if (a.quantifier === "never") {
|
|
18344
|
+
if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
|
|
18345
|
+
const first = calling[0];
|
|
18346
|
+
const step = called(first)[0];
|
|
18347
|
+
return fail(
|
|
18348
|
+
`${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run2.diagram, step.toServiceId)}: ${stepText(step)}`,
|
|
18349
|
+
`Take the call to ${step.toServiceId} out of ${quote(first.name)}, e.g. serve it from a cache`
|
|
18350
|
+
);
|
|
18351
|
+
}
|
|
18352
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
18353
|
+
if (a.quantifier === "some") {
|
|
18354
|
+
if (calling.length) return pass(`${sc.label} calls ${what}${multi ? ` in ${names(calling)}` : ""}`);
|
|
18355
|
+
return fail(`${sc.label} never calls ${what}`, `Add a step to ${what}`);
|
|
18356
|
+
}
|
|
18357
|
+
const missing = sc.scenarios.filter((s) => !calling.includes(s));
|
|
18358
|
+
if (missing.length === 0 && sc.scenarios.length) return pass(`Every scenario of ${sc.label} calls ${what}`);
|
|
18359
|
+
return fail(`${sc.label} does not call ${what} in ${names(missing) || "any scenario"}`, `Add a step to ${what} in ${names(missing) || "a scenario"}`);
|
|
18360
|
+
}
|
|
18361
|
+
function before(run2, a) {
|
|
18362
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18363
|
+
if (isCheck(sc)) return sc;
|
|
18364
|
+
const first = idsOf(run2, a.first);
|
|
18365
|
+
const then = idsOf(run2, a.then);
|
|
18366
|
+
const [x, y] = [selectorText(a.first), selectorText(a.then)];
|
|
18367
|
+
if (first.size === 0) return noMatch(a.first);
|
|
18368
|
+
if (then.size === 0) return noMatch(a.then);
|
|
18369
|
+
let checked = 0;
|
|
18370
|
+
for (const s of sc.scenarios) {
|
|
18371
|
+
const order = requestOrder(sc.u, s).requests;
|
|
18372
|
+
const iy = order.findIndex((m) => then.has(m.to));
|
|
18373
|
+
if (iy < 0) continue;
|
|
18374
|
+
checked++;
|
|
18375
|
+
const ix = order.findIndex((m) => first.has(m.to));
|
|
18376
|
+
if (ix < 0 || ix > iy) {
|
|
18377
|
+
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
18378
|
+
return fail(
|
|
18379
|
+
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)})`,
|
|
18380
|
+
`Call ${x} before the first call to ${y}`
|
|
18381
|
+
);
|
|
18382
|
+
}
|
|
18383
|
+
}
|
|
18384
|
+
if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
|
|
18385
|
+
return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
|
|
18386
|
+
}
|
|
18387
|
+
function neverWaits(run2, a) {
|
|
18388
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18389
|
+
if (isCheck(sc)) return sc;
|
|
18390
|
+
const ids = idsOf(run2, a.target);
|
|
18391
|
+
const what = selectorText(a.target);
|
|
18392
|
+
for (const s of sc.scenarios) {
|
|
18393
|
+
const hop = criticalPath(sc.u, s).flat().find((h) => !h.async && ids.has(h.target));
|
|
18394
|
+
if (hop) {
|
|
18395
|
+
const where = sc.scenarios.length > 1 ? quote(s.name) : sc.label;
|
|
18396
|
+
return fail(
|
|
18397
|
+
`${where} waits for ${nodeLabel(run2.diagram, hop.target)} before responding: ${stepText(hop.step)}${hop.failed ? " times out on the request path" : " is synchronous"}`,
|
|
18398
|
+
`Send it with ->> (through a queue if it must not be lost), or move it after the line that answers the entry request`
|
|
18399
|
+
);
|
|
18400
|
+
}
|
|
18401
|
+
}
|
|
18402
|
+
const calledAtAll = sc.scenarios.some((s) => s.steps.some((step) => ids.has(step.toServiceId)));
|
|
18403
|
+
return pass(`${sc.label} never waits for ${what}${calledAtAll ? "" : ` (it never calls ${what})`}`);
|
|
18404
|
+
}
|
|
18405
|
+
function after(run2, a) {
|
|
18406
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18407
|
+
if (isCheck(sc)) return sc;
|
|
18408
|
+
const targets = idsOf(run2, a.target);
|
|
18409
|
+
const firsts = idsOf(run2, a.after);
|
|
18410
|
+
const [y, x] = [selectorText(a.target), selectorText(a.after)];
|
|
18411
|
+
if (targets.size === 0) return noMatch(a.target);
|
|
18412
|
+
if (firsts.size === 0) return noMatch(a.after);
|
|
18413
|
+
let checked = 0;
|
|
18414
|
+
for (const s of sc.scenarios) {
|
|
18415
|
+
const order = requestOrder(sc.u, s).requests;
|
|
18416
|
+
const ix = order.findIndex((m) => firsts.has(m.to));
|
|
18417
|
+
const iy = order.map((m) => targets.has(m.to)).lastIndexOf(true);
|
|
18418
|
+
if (ix < 0 || iy < 0) continue;
|
|
18419
|
+
checked++;
|
|
18420
|
+
if (iy < ix) {
|
|
18421
|
+
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
18422
|
+
return fail(
|
|
18423
|
+
`${where}, the last call to ${y} (${stepText(order[iy].step)}) comes before the first call to ${x} (${stepText(order[ix].step)})`,
|
|
18424
|
+
`Call ${y} after ${x}: move the step to ${order[iy].to} below the call to ${order[ix].to}`
|
|
18425
|
+
);
|
|
18426
|
+
}
|
|
18427
|
+
}
|
|
18428
|
+
if (checked === 0) {
|
|
18429
|
+
const callsY = sc.scenarios.some((s) => s.steps.some((step) => targets.has(step.toServiceId)));
|
|
18430
|
+
const callsX = sc.scenarios.some((s) => s.steps.some((step) => firsts.has(step.toServiceId)));
|
|
18431
|
+
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`;
|
|
18432
|
+
return fail(`${sc.label} ${why}`, `Add a step to ${y} after the call to ${x}`);
|
|
18433
|
+
}
|
|
18434
|
+
return pass(`${sc.label} calls ${y} after ${x}${checked > 1 ? ` in all ${checked} scenarios that call both` : ""}`);
|
|
18435
|
+
}
|
|
18436
|
+
function senderCalls(run2, a) {
|
|
18437
|
+
let useCases = run2.diagram.useCases;
|
|
18438
|
+
if (a.useCase !== void 0) {
|
|
18439
|
+
const u = findUseCase(run2.diagram, a.useCase);
|
|
18440
|
+
if (!u) return unknownUseCase(run2, a.useCase);
|
|
18441
|
+
useCases = [u];
|
|
18442
|
+
}
|
|
18443
|
+
const from = idsOf(run2, a.from);
|
|
18444
|
+
const to = idsOf(run2, a.to);
|
|
18445
|
+
const [x, y] = [selectorText(a.from), selectorText(a.to)];
|
|
18446
|
+
const within = a.useCase !== void 0 ? ` in ${quote(useCases[0].name)}` : "";
|
|
18447
|
+
for (const u of useCases) {
|
|
18448
|
+
for (const s of u.scenarios) {
|
|
18449
|
+
const step = s.steps.find((st) => from.has(st.fromServiceId) && to.has(st.toServiceId));
|
|
18450
|
+
if (!step) continue;
|
|
18451
|
+
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
18452
|
+
if (a.quantifier === "some") return pass(`${x} calls ${y}${within}: ${stepText(step)} in ${where}`);
|
|
18453
|
+
return fail(
|
|
18454
|
+
`${x} calls ${y}${within}: ${stepText(step)} in ${where}`,
|
|
18455
|
+
`Let another node make this call (e.g. the client uploads to storage with a presigned URL), or remove the step`
|
|
18456
|
+
);
|
|
18457
|
+
}
|
|
18458
|
+
}
|
|
18459
|
+
if (a.quantifier === "never") return pass(`${x} never calls ${y}${within}`);
|
|
18460
|
+
if (from.size === 0) return noMatch(a.from);
|
|
18461
|
+
if (to.size === 0) return noMatch(a.to);
|
|
18462
|
+
return fail(`No step is sent by ${x} to ${y}${within}`, `Add a step ${[...from][0]} -> ${[...to][0]}${within}`);
|
|
18463
|
+
}
|
|
18464
|
+
function startsAt(run2, a) {
|
|
18465
|
+
const u = findUseCase(run2.diagram, a.useCase);
|
|
18466
|
+
if (!u) return unknownUseCase(run2, a.useCase);
|
|
18467
|
+
const ids = idsOf(run2, a.target);
|
|
18468
|
+
const what = selectorText(a.target);
|
|
18469
|
+
if (ids.size === 0) return noMatch(a.target);
|
|
18470
|
+
const entries = u.scenarios.map((s) => s.steps[0]).filter((s) => !!s);
|
|
18471
|
+
if (entries.length === 0) return fail(`${quote(u.name)} has no steps`, `Start "${u.name}" with a step sent by ${what}`);
|
|
18472
|
+
const wrong = entries.find((s) => !ids.has(s.fromServiceId));
|
|
18473
|
+
if (wrong) {
|
|
18474
|
+
return fail(
|
|
18475
|
+
`${quote(u.name)} starts at ${nodeLabel(run2.diagram, wrong.fromServiceId)}, not ${what}: ${stepText(wrong)}`,
|
|
18476
|
+
`Make the first step of "${u.name}" a step sent by ${what}`
|
|
18477
|
+
);
|
|
18478
|
+
}
|
|
18479
|
+
return pass(`${quote(u.name)} starts at ${nodeLabel(run2.diagram, entries[0].fromServiceId)}`);
|
|
18480
|
+
}
|
|
18481
|
+
function statusMatches(status, wanted) {
|
|
18482
|
+
if (status === void 0) return false;
|
|
18483
|
+
const cls = /^([1-5])xx$/i.exec(wanted);
|
|
18484
|
+
return cls ? Math.floor(status / 100) === Number(cls[1]) : String(status) === wanted;
|
|
18485
|
+
}
|
|
18486
|
+
function responds(run2, a) {
|
|
18487
|
+
const sc = scope(run2, a.useCase, a.scenario);
|
|
18488
|
+
if (isCheck(sc)) return sc;
|
|
18489
|
+
const hit = sc.scenarios.find((s) => statusMatches(s.steps[0]?.statusCode, a.status));
|
|
18490
|
+
if (hit) return pass(`${sc.label} responds ${hit.steps[0].statusCode}${sc.scenarios.length > 1 ? ` in ${quote(hit.name)}` : ""}`);
|
|
18491
|
+
const seen = [...new Set(sc.scenarios.map((s) => s.steps[0]?.failed ? "a failed call" : s.steps[0]?.statusCode ?? "no status"))];
|
|
18492
|
+
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}`);
|
|
18493
|
+
}
|
|
18494
|
+
function noPath(run2, from, to) {
|
|
18495
|
+
const sources = idsOf(run2, from);
|
|
18496
|
+
const targets = idsOf(run2, to);
|
|
18497
|
+
const [x, y] = [selectorText(from), selectorText(to)];
|
|
18498
|
+
const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
|
|
18499
|
+
const edge = run2.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
|
|
18500
|
+
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${lineOf(edge.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
18501
|
+
for (const u of run2.diagram.useCases) {
|
|
18502
|
+
for (const s of u.scenarios) {
|
|
18503
|
+
const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
|
|
18504
|
+
if (step) {
|
|
18505
|
+
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
18506
|
+
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${lineOf(step.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
18507
|
+
}
|
|
18508
|
+
}
|
|
18509
|
+
}
|
|
18510
|
+
return pass(`No connection or step goes directly from ${x} to ${y}`);
|
|
18511
|
+
}
|
|
18512
|
+
|
|
16346
18513
|
// src/fmt.ts
|
|
16347
18514
|
var FMT_USAGE = ` proschi fmt [--check] <file|dir>...`;
|
|
16348
18515
|
var FMT_HELP = `fmt Rewrites files in the canonical layout and lists the ones it changed.
|
|
@@ -16387,6 +18554,173 @@ function displayPath2(file) {
|
|
|
16387
18554
|
// src/imports.ts
|
|
16388
18555
|
var import_node_fs4 = require("node:fs");
|
|
16389
18556
|
var import_node_path5 = require("node:path");
|
|
18557
|
+
|
|
18558
|
+
// src/simulation.ts
|
|
18559
|
+
var SIM_USAGE = ` proschi test [--format text|github|json] <file|dir>...
|
|
18560
|
+
proschi analyze [--format text|json] <file>`;
|
|
18561
|
+
var SIM_HELP = `test Runs the requirements and test blocks of each file (imports are
|
|
18562
|
+
followed) against the simulation. Exits with 1 if any fails or a file
|
|
18563
|
+
has errors.
|
|
18564
|
+
analyze Prints the capacity table: reads and writes (load of capacity and
|
|
18565
|
+
utilisation), latency, availability, egress and cost per node; latency
|
|
18566
|
+
percentiles per use case and scenario.`;
|
|
18567
|
+
function testFiles(files, load = parseFile) {
|
|
18568
|
+
return files.map((file) => {
|
|
18569
|
+
const { diagram, diagnostics } = load(file);
|
|
18570
|
+
return { file: displayPath3(file), errors: diagnostics.filter((d) => d.severity === "error"), results: runTests(diagram) };
|
|
18571
|
+
});
|
|
18572
|
+
}
|
|
18573
|
+
var escape2 = (s) => s.replace(/%/g, "%25").replace(/\r?\n/g, "%0A");
|
|
18574
|
+
function formatTestReports(reports, style) {
|
|
18575
|
+
if (style === "json") return JSON.stringify(reports, null, 2);
|
|
18576
|
+
const lines = [];
|
|
18577
|
+
for (const { file, errors, results } of reports) {
|
|
18578
|
+
const where = (loc) => ({ file: loc?.file ?? file, line: loc?.line ?? 1, col: loc?.col ?? 1 });
|
|
18579
|
+
if (style === "github") {
|
|
18580
|
+
for (const e of errors) lines.push(`::error file=${e.file ?? file},line=${e.line},col=${e.col},title=proschi::${escape2(e.message)}`);
|
|
18581
|
+
for (const r of results.filter((r2) => !r2.passed)) {
|
|
18582
|
+
const at = where(r.loc);
|
|
18583
|
+
lines.push(`::error file=${at.file},line=${at.line},col=${at.col},title=proschi test: ${escape2(r.name)}::${escape2(r.hint ? `${r.message}. ${r.hint}` : r.message)}`);
|
|
18584
|
+
}
|
|
18585
|
+
continue;
|
|
18586
|
+
}
|
|
18587
|
+
lines.push(file);
|
|
18588
|
+
for (const e of errors) lines.push(` error ${e.file ? `${e.file}:` : ""}${e.line}:${e.col}: ${e.message}`);
|
|
18589
|
+
if (results.length === 0) lines.push(" no requirements or tests");
|
|
18590
|
+
for (const r of results) {
|
|
18591
|
+
const at = r.loc ? ` (${r.loc.file ? `${r.loc.file}:` : "line "}${r.loc.line})` : "";
|
|
18592
|
+
lines.push(` ${r.passed ? "\u2713" : "\u2717"} ${r.name}${r.passed ? "" : at}: ${r.message}`);
|
|
18593
|
+
if (!r.passed) {
|
|
18594
|
+
for (const a of r.assertions ?? []) if (!a.passed && r.assertions.length > 1) lines.push(` \u2717 line ${a.loc.line}: ${a.message}`);
|
|
18595
|
+
if (r.hint) lines.push(` \u2192 ${r.hint}`);
|
|
18596
|
+
}
|
|
18597
|
+
}
|
|
18598
|
+
}
|
|
18599
|
+
if (style === "text") {
|
|
18600
|
+
const all = reports.flatMap((r) => r.results);
|
|
18601
|
+
const failed = all.filter((r) => !r.passed).length;
|
|
18602
|
+
const errors = reports.reduce((n, r) => n + r.errors.length, 0);
|
|
18603
|
+
const files = reports.length === 1 ? "1 file" : `${reports.length} files`;
|
|
18604
|
+
lines.push(`${files}: ${all.length - failed} passed, ${failed} failed${errors ? `, ${errors} error(s)` : ""}`);
|
|
18605
|
+
}
|
|
18606
|
+
return lines.join("\n");
|
|
18607
|
+
}
|
|
18608
|
+
function parseArgs(args, styles, err) {
|
|
18609
|
+
let style = "text";
|
|
18610
|
+
const paths = [];
|
|
18611
|
+
for (let i = 0; i < args.length; i++) {
|
|
18612
|
+
const arg = args[i];
|
|
18613
|
+
if (arg === "--format") {
|
|
18614
|
+
const value = args[++i];
|
|
18615
|
+
if (!styles.includes(value)) {
|
|
18616
|
+
err(`Unknown format '${value ?? ""}'; use ${styles.join(", ")}`);
|
|
18617
|
+
return void 0;
|
|
18618
|
+
}
|
|
18619
|
+
style = value;
|
|
18620
|
+
} else if (arg.startsWith("-")) {
|
|
18621
|
+
err(`Unknown option ${arg}
|
|
18622
|
+
|
|
18623
|
+
Usage:
|
|
18624
|
+
${SIM_USAGE}`);
|
|
18625
|
+
return void 0;
|
|
18626
|
+
} else paths.push(arg);
|
|
18627
|
+
}
|
|
18628
|
+
if (paths.length === 0) {
|
|
18629
|
+
err(`Usage:
|
|
18630
|
+
${SIM_USAGE}`);
|
|
18631
|
+
return void 0;
|
|
18632
|
+
}
|
|
18633
|
+
return { style, paths };
|
|
18634
|
+
}
|
|
18635
|
+
function runTest(args, collectFiles2, out, err, load = parseFile) {
|
|
18636
|
+
const parsed = parseArgs(args, ["text", "github", "json"], err);
|
|
18637
|
+
if (!parsed) return 2;
|
|
18638
|
+
let reports;
|
|
18639
|
+
try {
|
|
18640
|
+
reports = testFiles(collectFiles2(parsed.paths), load);
|
|
18641
|
+
} catch (e) {
|
|
18642
|
+
err(String(e.message));
|
|
18643
|
+
return 2;
|
|
18644
|
+
}
|
|
18645
|
+
const text = formatTestReports(reports, parsed.style);
|
|
18646
|
+
if (text) out(text);
|
|
18647
|
+
return reports.some((r) => r.errors.length > 0 || r.results.some((t) => !t.passed)) ? 1 : 0;
|
|
18648
|
+
}
|
|
18649
|
+
function runAnalyze(args, out, err, load = parseFile) {
|
|
18650
|
+
const parsed = parseArgs(args, ["text", "json"], err);
|
|
18651
|
+
if (!parsed) return 2;
|
|
18652
|
+
if (parsed.paths.length !== 1) {
|
|
18653
|
+
err(`Usage:
|
|
18654
|
+
${SIM_USAGE}`);
|
|
18655
|
+
return 2;
|
|
18656
|
+
}
|
|
18657
|
+
const [file] = parsed.paths;
|
|
18658
|
+
let result;
|
|
18659
|
+
try {
|
|
18660
|
+
result = load(file);
|
|
18661
|
+
} catch (e) {
|
|
18662
|
+
err(String(e.message));
|
|
18663
|
+
return 2;
|
|
18664
|
+
}
|
|
18665
|
+
const analysis = analyze(result.diagram);
|
|
18666
|
+
if (parsed.style === "json") out(JSON.stringify({ file: displayPath3(file), analysis }, null, 2));
|
|
18667
|
+
else out(capacityReport(result.diagram, analysis, displayPath3(file)));
|
|
18668
|
+
return result.diagnostics.some((d) => d.severity === "error") ? 1 : 0;
|
|
18669
|
+
}
|
|
18670
|
+
function table(rows, left = 1) {
|
|
18671
|
+
const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => r[c].length)));
|
|
18672
|
+
return rows.map((r) => r.map((cell, c) => c < left ? cell.padEnd(widths[c]) : cell.padStart(widths[c])).join(" ").trimEnd());
|
|
18673
|
+
}
|
|
18674
|
+
function accessCell(load, capacity, utilization) {
|
|
18675
|
+
return load > 0 ? `${formatRps(load).replace(" rps", "")}/${formatRps(capacity)} ${formatPercent(utilization)}` : "-";
|
|
18676
|
+
}
|
|
18677
|
+
function formatGb(gb) {
|
|
18678
|
+
const n = (x, digits) => Number(x.toFixed(digits)).toLocaleString("en-US");
|
|
18679
|
+
return gb >= 1e3 ? `${n(gb / 1e3, 1)} TB` : `${n(gb, gb < 10 ? 1 : 0)} GB`;
|
|
18680
|
+
}
|
|
18681
|
+
function egressCell(n) {
|
|
18682
|
+
return n.egressGbPerMonth > 0 ? `${formatUsd(n.egressUsd).replace("/month", "")} (${formatGb(n.egressGbPerMonth)})` : "-";
|
|
18683
|
+
}
|
|
18684
|
+
function capacityReport(diagram, analysis, file) {
|
|
18685
|
+
const lines = [diagram.title ? `${diagram.title} (${file})` : file, ""];
|
|
18686
|
+
if ((diagram.traffic ?? []).length === 0) lines.push("No traffic { \u2026 } block: loads are zero and latencies are at idle.", "");
|
|
18687
|
+
const nodes = analysis.nodes.filter((n) => n.kind !== "client" && n.kind !== "other");
|
|
18688
|
+
lines.push(
|
|
18689
|
+
...table([
|
|
18690
|
+
["Node", "Kind", "Replicas", "Reads", "Writes", "Util", "Latency", "Availability", "Egress/month", "Cost/month"],
|
|
18691
|
+
...nodes.map((n) => [
|
|
18692
|
+
n.id,
|
|
18693
|
+
n.kind,
|
|
18694
|
+
n.shards > 1 ? `${n.replicas}x${n.shards} shards` : String(n.replicas),
|
|
18695
|
+
accessCell(n.readLoadRps, n.readCapacityRps, n.readUtilization),
|
|
18696
|
+
accessCell(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization),
|
|
18697
|
+
`${formatPercent(n.utilization)}${n.saturated ? " SATURATED" : n.utilization > HOT ? " hot" : ""}`,
|
|
18698
|
+
formatMs(n.latencyMs),
|
|
18699
|
+
formatAvailability(n.availability),
|
|
18700
|
+
egressCell(n),
|
|
18701
|
+
formatUsd(n.costUsd).replace("/month", "")
|
|
18702
|
+
])
|
|
18703
|
+
], 2),
|
|
18704
|
+
"",
|
|
18705
|
+
`Total cost: ${formatUsd(analysis.totalCostUsd)}${analysis.totalEgressUsd > 0 ? ` (${formatUsd(analysis.totalEgressUsd)} of it egress)` : ""}`,
|
|
18706
|
+
""
|
|
18707
|
+
);
|
|
18708
|
+
if (analysis.useCases.length) {
|
|
18709
|
+
const rows = [["Use case", "Traffic", "p50", "p95", "p99", "p99.9", "Availability"]];
|
|
18710
|
+
for (const u of analysis.useCases) {
|
|
18711
|
+
rows.push([u.name, u.rps > 0 ? formatRps(u.rps) : "-", ...["p50", "p95", "p99", "p999"].map((p) => formatMs(u.percentiles[p])), formatAvailability(u.availability)]);
|
|
18712
|
+
if (u.scenarios.length > 1) {
|
|
18713
|
+
for (const s of u.scenarios) rows.push([` ${s.name}`, formatPercent(s.share), formatMs(s.percentiles.p50), formatMs(s.percentiles.p95), formatMs(s.percentiles.p99), formatMs(s.percentiles.p999), ""]);
|
|
18714
|
+
}
|
|
18715
|
+
}
|
|
18716
|
+
lines.push(...table(rows), "");
|
|
18717
|
+
}
|
|
18718
|
+
lines.push(`Single points of failure: ${analysis.singlePointsOfFailure.join(", ") || "none"}`);
|
|
18719
|
+
if (analysis.warnings.length) lines.push("", "Warnings:", ...analysis.warnings.map((w) => ` ${w}`));
|
|
18720
|
+
return lines.join("\n");
|
|
18721
|
+
}
|
|
18722
|
+
|
|
18723
|
+
// src/imports.ts
|
|
16390
18724
|
function displayPath3(file, cwd = process.cwd()) {
|
|
16391
18725
|
const abs = (0, import_node_path5.resolve)(cwd, file);
|
|
16392
18726
|
const rel = (0, import_node_path5.relative)(cwd, abs);
|
|
@@ -16422,12 +18756,13 @@ function checkFiles(files, extra) {
|
|
|
16422
18756
|
}
|
|
16423
18757
|
|
|
16424
18758
|
// src/cli.ts
|
|
16425
|
-
var VERSION = true ? "0.
|
|
18759
|
+
var VERSION = true ? "0.4.0" : "dev";
|
|
16426
18760
|
var USAGE = `Usage:
|
|
16427
18761
|
proschi check [--strict] [--format text|github|json] [--openapi <node>=<spec>]... <file|dir>...
|
|
16428
18762
|
proschi parse <file>
|
|
16429
18763
|
${RENDER_USAGE}
|
|
16430
18764
|
${FMT_USAGE}
|
|
18765
|
+
${SIM_USAGE}
|
|
16431
18766
|
proschi --version
|
|
16432
18767
|
|
|
16433
18768
|
check Reports errors and warnings. Directories are searched for *.proschi files.
|
|
@@ -16439,7 +18774,8 @@ check Reports errors and warnings. Directories are searched for *.proschi file
|
|
|
16439
18774
|
parse Prints {"diagram", "diagnostics"} as JSON; the shape is described by
|
|
16440
18775
|
schema/proschi-diagram.schema.json.
|
|
16441
18776
|
${RENDER_HELP}
|
|
16442
|
-
${FMT_HELP}
|
|
18777
|
+
${FMT_HELP}
|
|
18778
|
+
${SIM_HELP}`;
|
|
16443
18779
|
function collectFiles(paths) {
|
|
16444
18780
|
const out = [];
|
|
16445
18781
|
const walk = (dir) => {
|
|
@@ -16497,6 +18833,8 @@ function run(argv, out = console.log, err = console.error) {
|
|
|
16497
18833
|
}
|
|
16498
18834
|
if (command === "render") return import("./render.cjs").then((m) => m.renderCommand(rest, out, err, USAGE));
|
|
16499
18835
|
if (command === "fmt") return runFmt(rest, collectFiles, out, err);
|
|
18836
|
+
if (command === "test") return runTest(rest, collectFiles, out, err);
|
|
18837
|
+
if (command === "analyze") return runAnalyze(rest, out, err);
|
|
16500
18838
|
if (command === "check") {
|
|
16501
18839
|
let strict = false;
|
|
16502
18840
|
let style = "text";
|