proschi 0.3.0 → 0.5.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 +9 -0
- package/dist/cli.cjs +1507 -245
- package/dist/render.cjs +176 -176
- package/dist/server.cjs +787 -191
- package/grammar/proschi.tmLanguage.json +15 -3
- package/package.json +1 -1
- package/schema/proschi-diagram.schema.json +203 -1
package/dist/cli.cjs
CHANGED
|
@@ -2986,7 +2986,7 @@ var require_compile = __commonJS({
|
|
|
2986
2986
|
const schOrFunc = root.refs[ref];
|
|
2987
2987
|
if (schOrFunc)
|
|
2988
2988
|
return schOrFunc;
|
|
2989
|
-
let _sch =
|
|
2989
|
+
let _sch = resolve4.call(this, root, ref);
|
|
2990
2990
|
if (_sch === void 0) {
|
|
2991
2991
|
const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
|
|
2992
2992
|
const { schemaId } = this.opts;
|
|
@@ -3013,7 +3013,7 @@ var require_compile = __commonJS({
|
|
|
3013
3013
|
function sameSchemaEnv(s1, s2) {
|
|
3014
3014
|
return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
|
|
3015
3015
|
}
|
|
3016
|
-
function
|
|
3016
|
+
function resolve4(root, ref) {
|
|
3017
3017
|
let sch;
|
|
3018
3018
|
while (typeof (sch = this.refs[ref]) == "string")
|
|
3019
3019
|
ref = sch;
|
|
@@ -3843,7 +3843,7 @@ var require_fast_uri = __commonJS({
|
|
|
3843
3843
|
}
|
|
3844
3844
|
return uri;
|
|
3845
3845
|
}
|
|
3846
|
-
function
|
|
3846
|
+
function resolve4(baseURI, relativeURI, options) {
|
|
3847
3847
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
3848
3848
|
const {
|
|
3849
3849
|
parsed: baseParsed,
|
|
@@ -4212,7 +4212,7 @@ var require_fast_uri = __commonJS({
|
|
|
4212
4212
|
var fastUri = {
|
|
4213
4213
|
SCHEMES,
|
|
4214
4214
|
normalize,
|
|
4215
|
-
resolve:
|
|
4215
|
+
resolve: resolve4,
|
|
4216
4216
|
resolveComponent,
|
|
4217
4217
|
equal,
|
|
4218
4218
|
serialize,
|
|
@@ -11869,13 +11869,13 @@ var require_resolve_block_scalar = __commonJS({
|
|
|
11869
11869
|
"node_modules/yaml/dist/compose/resolve-block-scalar.js"(exports2) {
|
|
11870
11870
|
"use strict";
|
|
11871
11871
|
var Scalar = require_Scalar();
|
|
11872
|
-
function resolveBlockScalar(ctx,
|
|
11873
|
-
const start =
|
|
11874
|
-
const header = parseBlockScalarHeader(
|
|
11872
|
+
function resolveBlockScalar(ctx, scalar2, onError) {
|
|
11873
|
+
const start = scalar2.offset;
|
|
11874
|
+
const header = parseBlockScalarHeader(scalar2, ctx.options.strict, onError);
|
|
11875
11875
|
if (!header)
|
|
11876
11876
|
return { value: "", type: null, comment: "", range: [start, start, start] };
|
|
11877
11877
|
const type = header.mode === ">" ? Scalar.Scalar.BLOCK_FOLDED : Scalar.Scalar.BLOCK_LITERAL;
|
|
11878
|
-
const lines =
|
|
11878
|
+
const lines = scalar2.source ? splitLines(scalar2.source) : [];
|
|
11879
11879
|
let chompStart = lines.length;
|
|
11880
11880
|
for (let i = lines.length - 1; i >= 0; --i) {
|
|
11881
11881
|
const content = lines[i][1];
|
|
@@ -11887,12 +11887,12 @@ var require_resolve_block_scalar = __commonJS({
|
|
|
11887
11887
|
if (chompStart === 0) {
|
|
11888
11888
|
const value2 = header.chomp === "+" && lines.length > 0 ? "\n".repeat(Math.max(1, lines.length - 1)) : "";
|
|
11889
11889
|
let end2 = start + header.length;
|
|
11890
|
-
if (
|
|
11891
|
-
end2 +=
|
|
11890
|
+
if (scalar2.source)
|
|
11891
|
+
end2 += scalar2.source.length;
|
|
11892
11892
|
return { value: value2, type, comment: header.comment, range: [start, end2, end2] };
|
|
11893
11893
|
}
|
|
11894
|
-
let trimIndent =
|
|
11895
|
-
let offset =
|
|
11894
|
+
let trimIndent = scalar2.indent + header.indent;
|
|
11895
|
+
let offset = scalar2.offset + header.length;
|
|
11896
11896
|
let contentStart = 0;
|
|
11897
11897
|
for (let i = 0; i < chompStart; ++i) {
|
|
11898
11898
|
const [indent2, content] = lines[i];
|
|
@@ -11970,7 +11970,7 @@ var require_resolve_block_scalar = __commonJS({
|
|
|
11970
11970
|
default:
|
|
11971
11971
|
value += "\n";
|
|
11972
11972
|
}
|
|
11973
|
-
const end = start + header.length +
|
|
11973
|
+
const end = start + header.length + scalar2.source.length;
|
|
11974
11974
|
return { value, type, comment: header.comment, range: [start, end, end] };
|
|
11975
11975
|
}
|
|
11976
11976
|
function parseBlockScalarHeader({ offset, props }, strict, onError) {
|
|
@@ -12053,8 +12053,8 @@ var require_resolve_flow_scalar = __commonJS({
|
|
|
12053
12053
|
"use strict";
|
|
12054
12054
|
var Scalar = require_Scalar();
|
|
12055
12055
|
var resolveEnd = require_resolve_end();
|
|
12056
|
-
function resolveFlowScalar(
|
|
12057
|
-
const { offset, type, source: source2, end } =
|
|
12056
|
+
function resolveFlowScalar(scalar2, strict, onError) {
|
|
12057
|
+
const { offset, type, source: source2, end } = scalar2;
|
|
12058
12058
|
let _type;
|
|
12059
12059
|
let value;
|
|
12060
12060
|
const _onError = (rel, code, msg) => onError(offset + rel, code, msg);
|
|
@@ -12073,7 +12073,7 @@ var require_resolve_flow_scalar = __commonJS({
|
|
|
12073
12073
|
break;
|
|
12074
12074
|
/* istanbul ignore next should not happen */
|
|
12075
12075
|
default:
|
|
12076
|
-
onError(
|
|
12076
|
+
onError(scalar2, "UNEXPECTED_TOKEN", `Expected a flow scalar value, but found: ${type}`);
|
|
12077
12077
|
return {
|
|
12078
12078
|
value: "",
|
|
12079
12079
|
type: null,
|
|
@@ -12288,26 +12288,26 @@ var require_compose_scalar = __commonJS({
|
|
|
12288
12288
|
tag = findScalarTagByTest(ctx, value, token, onError);
|
|
12289
12289
|
else
|
|
12290
12290
|
tag = ctx.schema[identity.SCALAR];
|
|
12291
|
-
let
|
|
12291
|
+
let scalar2;
|
|
12292
12292
|
try {
|
|
12293
12293
|
const res = tag.resolve(value, (msg) => onError(tagToken ?? token, "TAG_RESOLVE_FAILED", msg), ctx.options);
|
|
12294
|
-
|
|
12294
|
+
scalar2 = identity.isScalar(res) ? res : new Scalar.Scalar(res);
|
|
12295
12295
|
} catch (error) {
|
|
12296
12296
|
const msg = error instanceof Error ? error.message : String(error);
|
|
12297
12297
|
onError(tagToken ?? token, "TAG_RESOLVE_FAILED", msg);
|
|
12298
|
-
|
|
12298
|
+
scalar2 = new Scalar.Scalar(value);
|
|
12299
12299
|
}
|
|
12300
|
-
|
|
12301
|
-
|
|
12300
|
+
scalar2.range = range;
|
|
12301
|
+
scalar2.source = value;
|
|
12302
12302
|
if (type)
|
|
12303
|
-
|
|
12303
|
+
scalar2.type = type;
|
|
12304
12304
|
if (tagName)
|
|
12305
|
-
|
|
12305
|
+
scalar2.tag = tagName;
|
|
12306
12306
|
if (tag.format)
|
|
12307
|
-
|
|
12307
|
+
scalar2.format = tag.format;
|
|
12308
12308
|
if (comment)
|
|
12309
|
-
|
|
12310
|
-
return
|
|
12309
|
+
scalar2.comment = comment;
|
|
12310
|
+
return scalar2;
|
|
12311
12311
|
}
|
|
12312
12312
|
function findScalarTagByName(schema, value, tagName, tagToken, onError) {
|
|
12313
12313
|
if (tagName === "!")
|
|
@@ -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 quote3 = this.charAt(0);
|
|
13468
|
+
let end = this.buffer.indexOf(quote3, this.pos + 1);
|
|
13469
|
+
if (quote3 === "'") {
|
|
13470
13470
|
while (end !== -1 && this.buffer[end + 1] === "'")
|
|
13471
13471
|
end = this.buffer.indexOf("'", end + 2);
|
|
13472
13472
|
} else {
|
|
@@ -14135,37 +14135,37 @@ var require_parser = __commonJS({
|
|
|
14135
14135
|
};
|
|
14136
14136
|
}
|
|
14137
14137
|
}
|
|
14138
|
-
*scalar(
|
|
14138
|
+
*scalar(scalar2) {
|
|
14139
14139
|
if (this.type === "map-value-ind") {
|
|
14140
14140
|
const prev = getPrevProps(this.peek(2));
|
|
14141
14141
|
const start = getFirstKeyStartProps(prev);
|
|
14142
14142
|
let sep;
|
|
14143
|
-
if (
|
|
14144
|
-
sep =
|
|
14143
|
+
if (scalar2.end) {
|
|
14144
|
+
sep = scalar2.end;
|
|
14145
14145
|
sep.push(this.sourceToken);
|
|
14146
|
-
delete
|
|
14146
|
+
delete scalar2.end;
|
|
14147
14147
|
} else
|
|
14148
14148
|
sep = [this.sourceToken];
|
|
14149
14149
|
const map = {
|
|
14150
14150
|
type: "block-map",
|
|
14151
|
-
offset:
|
|
14152
|
-
indent:
|
|
14153
|
-
items: [{ start, key:
|
|
14151
|
+
offset: scalar2.offset,
|
|
14152
|
+
indent: scalar2.indent,
|
|
14153
|
+
items: [{ start, key: scalar2, sep }]
|
|
14154
14154
|
};
|
|
14155
14155
|
this.onKeyLine = true;
|
|
14156
14156
|
this.stack[this.stack.length - 1] = map;
|
|
14157
14157
|
} else
|
|
14158
|
-
yield* this.lineEnd(
|
|
14158
|
+
yield* this.lineEnd(scalar2);
|
|
14159
14159
|
}
|
|
14160
|
-
*blockScalar(
|
|
14160
|
+
*blockScalar(scalar2) {
|
|
14161
14161
|
switch (this.type) {
|
|
14162
14162
|
case "space":
|
|
14163
14163
|
case "comment":
|
|
14164
14164
|
case "newline":
|
|
14165
|
-
|
|
14165
|
+
scalar2.props.push(this.sourceToken);
|
|
14166
14166
|
return;
|
|
14167
14167
|
case "scalar":
|
|
14168
|
-
|
|
14168
|
+
scalar2.source = this.source;
|
|
14169
14169
|
this.atNewLine = true;
|
|
14170
14170
|
this.indent = 0;
|
|
14171
14171
|
if (this.onNewLine) {
|
|
@@ -14797,8 +14797,8 @@ __export(cli_exports, {
|
|
|
14797
14797
|
run: () => run
|
|
14798
14798
|
});
|
|
14799
14799
|
module.exports = __toCommonJS(cli_exports);
|
|
14800
|
-
var
|
|
14801
|
-
var
|
|
14800
|
+
var import_node_fs6 = require("node:fs");
|
|
14801
|
+
var import_node_path7 = require("node:path");
|
|
14802
14802
|
|
|
14803
14803
|
// src/openapi/config.ts
|
|
14804
14804
|
var import_node_fs2 = require("node:fs");
|
|
@@ -15337,7 +15337,7 @@ var IDENT_PART = /[A-Za-z0-9_]/;
|
|
|
15337
15337
|
var TEAM_PART = /[A-Za-z0-9_-]/;
|
|
15338
15338
|
var DIGIT = /[0-9]/;
|
|
15339
15339
|
var LETTER = /[A-Za-z]/;
|
|
15340
|
-
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month)(?![A-Za-z0-9_/])/;
|
|
15340
|
+
var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month|usd\/GB|MB\/s|GB\/s)(?![A-Za-z0-9_/])/;
|
|
15341
15341
|
function tokenizeLine(text, line) {
|
|
15342
15342
|
const tokens = [];
|
|
15343
15343
|
const diagnostics = [];
|
|
@@ -15464,15 +15464,15 @@ function readString(text, start) {
|
|
|
15464
15464
|
}
|
|
15465
15465
|
function stripTrailingComment(text) {
|
|
15466
15466
|
let depth = 0;
|
|
15467
|
-
let
|
|
15467
|
+
let quote3 = null;
|
|
15468
15468
|
for (let i = 0; i < text.length; i++) {
|
|
15469
15469
|
const ch = text[i];
|
|
15470
|
-
if (
|
|
15470
|
+
if (quote3) {
|
|
15471
15471
|
if (ch === "\\") i++;
|
|
15472
|
-
else if (ch ===
|
|
15472
|
+
else if (ch === quote3) quote3 = null;
|
|
15473
15473
|
continue;
|
|
15474
15474
|
}
|
|
15475
|
-
if (ch === '"')
|
|
15475
|
+
if (ch === '"') quote3 = ch;
|
|
15476
15476
|
else if (ch === "{" || ch === "[") depth++;
|
|
15477
15477
|
else if (ch === "}" || ch === "]") depth = Math.max(0, depth - 1);
|
|
15478
15478
|
else if (ch === "#" && depth === 0 && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i);
|
|
@@ -15529,11 +15529,21 @@ function endpointGroupKey(endpoint, all) {
|
|
|
15529
15529
|
}
|
|
15530
15530
|
|
|
15531
15531
|
// ../frontend/src/dsl/kinds.ts
|
|
15532
|
-
var KINDS = ["client", "edge", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
15532
|
+
var KINDS = ["client", "edge", "cdn", "loadbalancer", "gateway", "dns", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
|
|
15533
15533
|
var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
|
|
15534
|
+
var EDGE_KINDS = ["edge", "cdn", "loadbalancer", "gateway", "dns"];
|
|
15534
15535
|
var BY_TECH = {
|
|
15535
|
-
"AWS
|
|
15536
|
-
"Azure
|
|
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",
|
|
15537
15547
|
Redis: "cache",
|
|
15538
15548
|
Elasticsearch: "search",
|
|
15539
15549
|
"GCP BigQuery": "analytics",
|
|
@@ -15565,6 +15575,12 @@ function isKind(word) {
|
|
|
15565
15575
|
function isDataStore(kind) {
|
|
15566
15576
|
return DATA_STORE_KINDS.includes(kind);
|
|
15567
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
|
+
}
|
|
15568
15584
|
|
|
15569
15585
|
// ../frontend/src/dsl/quantity.ts
|
|
15570
15586
|
function parseQuantity(text) {
|
|
@@ -15576,11 +15592,11 @@ function parseQuantity(text) {
|
|
|
15576
15592
|
if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
|
|
15577
15593
|
const magnitude = MAGNITUDES.get(suffix[0]);
|
|
15578
15594
|
const rest = suffix.slice(1);
|
|
15579
|
-
const
|
|
15580
|
-
if (magnitude && (rest === "" ||
|
|
15581
|
-
return
|
|
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) };
|
|
15582
15598
|
}
|
|
15583
|
-
return { error: `Unknown unit '${suffix}' in '${text}'; use rps, rpm, rpd, ms, s,
|
|
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)` };
|
|
15584
15600
|
}
|
|
15585
15601
|
var round = (n) => Number(n.toPrecision(12));
|
|
15586
15602
|
var MAGNITUDES = /* @__PURE__ */ new Map([
|
|
@@ -15595,9 +15611,67 @@ var UNITS = /* @__PURE__ */ new Map([
|
|
|
15595
15611
|
["ms", { unit: "ms", factor: 1 }],
|
|
15596
15612
|
["s", { unit: "ms", factor: 1e3 }],
|
|
15597
15613
|
["%", { unit: "%", factor: 1 }],
|
|
15598
|
-
["usd/month", { unit: "usd/month", 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 }]
|
|
15599
15618
|
]);
|
|
15600
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
|
+
|
|
15601
15675
|
// ../frontend/src/dsl/parser.ts
|
|
15602
15676
|
var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
15603
15677
|
var DEFAULT_TECH = "Rectangle";
|
|
@@ -15610,10 +15684,13 @@ var UNIT_EXAMPLES = {
|
|
|
15610
15684
|
rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
|
|
15611
15685
|
ms: "a duration, e.g. 50ms or 1.5s",
|
|
15612
15686
|
"%": "a percentage, e.g. 99.9%",
|
|
15613
|
-
"usd/month": "a monthly cost, e.g. 400 usd/month"
|
|
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"
|
|
15614
15690
|
};
|
|
15615
|
-
var
|
|
15616
|
-
var
|
|
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";
|
|
15617
15694
|
var techByName = new Map(
|
|
15618
15695
|
componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
|
|
15619
15696
|
);
|
|
@@ -15801,14 +15878,14 @@ var Parser = class {
|
|
|
15801
15878
|
const loc = this.locOf(pathToken, line);
|
|
15802
15879
|
const entry = { path, loc };
|
|
15803
15880
|
this.imports.push(entry);
|
|
15804
|
-
const { resolve:
|
|
15805
|
-
if (!
|
|
15881
|
+
const { resolve: resolve4 } = this.options;
|
|
15882
|
+
if (!resolve4) {
|
|
15806
15883
|
this.warning(`Imports are not resolved in this context; '${path}' was not loaded`, loc);
|
|
15807
15884
|
return;
|
|
15808
15885
|
}
|
|
15809
15886
|
let target;
|
|
15810
15887
|
try {
|
|
15811
|
-
target =
|
|
15888
|
+
target = resolve4(path, this.file ?? this.options.path);
|
|
15812
15889
|
} catch {
|
|
15813
15890
|
target = void 0;
|
|
15814
15891
|
}
|
|
@@ -16044,7 +16121,20 @@ var Parser = class {
|
|
|
16044
16121
|
if (top?.kind === "usecase" || top?.kind === "alt" || top?.kind === "par") {
|
|
16045
16122
|
const body = this.currentBody();
|
|
16046
16123
|
body.openAlt = void 0;
|
|
16047
|
-
|
|
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
|
+
});
|
|
16048
16138
|
} else if (arrow === "-x") {
|
|
16049
16139
|
this.error("'-x' marks a failed call and is only allowed in use case steps", this.locOf(arrowToken, line));
|
|
16050
16140
|
} else {
|
|
@@ -16054,6 +16144,42 @@ var Parser = class {
|
|
|
16054
16144
|
}
|
|
16055
16145
|
return lastLine;
|
|
16056
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
|
+
}
|
|
16057
16183
|
// ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
|
|
16058
16184
|
/**
|
|
16059
16185
|
* Starts a section block. Sections are top level only; a misplaced one, or
|
|
@@ -16177,10 +16303,14 @@ var Parser = class {
|
|
|
16177
16303
|
useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
|
|
16178
16304
|
i++;
|
|
16179
16305
|
};
|
|
16180
|
-
const commit = (requirement2) => {
|
|
16306
|
+
const commit = (requirement2, scenario) => {
|
|
16181
16307
|
this.requirements.push(requirement2);
|
|
16182
16308
|
const named = useCase;
|
|
16183
|
-
if (named)
|
|
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
|
+
});
|
|
16184
16314
|
};
|
|
16185
16315
|
const withUseCase = () => useCase ? { useCase: useCase.name } : {};
|
|
16186
16316
|
if (/^p\d+$/.test(word)) {
|
|
@@ -16190,10 +16320,24 @@ var Parser = class {
|
|
|
16190
16320
|
return;
|
|
16191
16321
|
}
|
|
16192
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
|
+
}
|
|
16193
16337
|
if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
|
|
16194
16338
|
const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
|
|
16195
16339
|
if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
|
|
16196
|
-
commit({ kind: "latency", percentile, ...withUseCase(), maxMs, loc });
|
|
16340
|
+
commit({ kind: "latency", percentile, ...withUseCase(), ...scenario ? { scenario: scenario.name } : {}, maxMs, loc }, scenario);
|
|
16197
16341
|
return;
|
|
16198
16342
|
}
|
|
16199
16343
|
switch (word) {
|
|
@@ -16276,8 +16420,50 @@ var Parser = class {
|
|
|
16276
16420
|
part = "durable or volatile";
|
|
16277
16421
|
override.durable = t.value === "durable";
|
|
16278
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;
|
|
16279
16460
|
} else {
|
|
16280
|
-
this.error(`Unexpected ${describe(t)}; expected
|
|
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));
|
|
16281
16467
|
return;
|
|
16282
16468
|
}
|
|
16283
16469
|
if (seen.has(part)) {
|
|
@@ -16288,7 +16474,7 @@ var Parser = class {
|
|
|
16288
16474
|
}
|
|
16289
16475
|
const idLoc = this.locOf(id, line);
|
|
16290
16476
|
if (seen.size === 0) {
|
|
16291
|
-
this.warning(`Capacity line for '${id.value}' overrides nothing; add
|
|
16477
|
+
this.warning(`Capacity line for '${id.value}' overrides nothing; add ${CAPACITY_PARTS}`, idLoc);
|
|
16292
16478
|
return;
|
|
16293
16479
|
}
|
|
16294
16480
|
const first = this.capacity.find((c) => c.node === id.value);
|
|
@@ -16298,7 +16484,14 @@ var Parser = class {
|
|
|
16298
16484
|
}
|
|
16299
16485
|
this.capacity.push(override);
|
|
16300
16486
|
this.deferred.push(() => {
|
|
16301
|
-
|
|
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);
|
|
16302
16495
|
});
|
|
16303
16496
|
}
|
|
16304
16497
|
/** `entity Url [in db] ["description"] {` */
|
|
@@ -16483,10 +16676,28 @@ var Parser = class {
|
|
|
16483
16676
|
this.error(ASSERTION_HELP, this.locOf(first, line));
|
|
16484
16677
|
return;
|
|
16485
16678
|
}
|
|
16486
|
-
const
|
|
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);
|
|
16487
16681
|
if (!target) return;
|
|
16488
|
-
|
|
16489
|
-
|
|
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));
|
|
16490
16701
|
return;
|
|
16491
16702
|
}
|
|
16492
16703
|
const i2 = target.next + 1;
|
|
@@ -16527,6 +16738,10 @@ var Parser = class {
|
|
|
16527
16738
|
const then = selector(target.next + 1);
|
|
16528
16739
|
if (!then) return;
|
|
16529
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);
|
|
16530
16745
|
} else {
|
|
16531
16746
|
assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
|
|
16532
16747
|
}
|
|
@@ -16535,6 +16750,16 @@ var Parser = class {
|
|
|
16535
16750
|
const target = selector(i + 3);
|
|
16536
16751
|
if (!target) return;
|
|
16537
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);
|
|
16538
16763
|
} else if (isWord(verb, "never")) {
|
|
16539
16764
|
if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
|
|
16540
16765
|
const target = selector(i + 2);
|
|
@@ -16576,8 +16801,23 @@ var Parser = class {
|
|
|
16576
16801
|
}
|
|
16577
16802
|
return assertion2;
|
|
16578
16803
|
}
|
|
16579
|
-
/** A node id, `[Tech]
|
|
16804
|
+
/** A selector starting at `tokens[i]`: one or more of node id, `[Tech]`, `any <kind>`, `any strong store`, joined by `or`. */
|
|
16580
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) {
|
|
16581
16821
|
const t = tokens[i];
|
|
16582
16822
|
if (t?.kind === "tech") {
|
|
16583
16823
|
const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
|
|
@@ -16585,8 +16825,15 @@ var Parser = class {
|
|
|
16585
16825
|
}
|
|
16586
16826
|
if (isWord(t, "any")) {
|
|
16587
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
|
+
}
|
|
16588
16835
|
if (kind?.kind !== "ident" || !isKind(kind.value)) {
|
|
16589
|
-
this.error(`Expected a kind after any: ${KINDS.join(", ")}`, this.locOf(kind ?? t, line));
|
|
16836
|
+
this.error(`Expected a kind after any: ${KINDS.join(", ")}, or strong store / eventual store`, this.locOf(kind ?? t, line));
|
|
16590
16837
|
return void 0;
|
|
16591
16838
|
}
|
|
16592
16839
|
return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
|
|
@@ -16596,12 +16843,14 @@ var Parser = class {
|
|
|
16596
16843
|
return void 0;
|
|
16597
16844
|
}
|
|
16598
16845
|
/** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
|
|
16599
|
-
checkSelectorLater({
|
|
16600
|
-
|
|
16601
|
-
|
|
16602
|
-
|
|
16603
|
-
|
|
16604
|
-
|
|
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
|
+
}
|
|
16605
16854
|
}
|
|
16606
16855
|
}
|
|
16607
16856
|
checkUseCase(name, loc) {
|
|
@@ -16615,10 +16864,10 @@ var Parser = class {
|
|
|
16615
16864
|
this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
|
|
16616
16865
|
}
|
|
16617
16866
|
/** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
|
|
16618
|
-
quantityOf(token, unit,
|
|
16867
|
+
quantityOf(token, unit, after2, line) {
|
|
16619
16868
|
const expected = UNIT_EXAMPLES[unit];
|
|
16620
16869
|
if (token?.kind !== "quantity" && token?.kind !== "number") {
|
|
16621
|
-
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ??
|
|
16870
|
+
this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after2, line));
|
|
16622
16871
|
return void 0;
|
|
16623
16872
|
}
|
|
16624
16873
|
const loc = this.locOf(token, line);
|
|
@@ -16721,7 +16970,10 @@ var Parser = class {
|
|
|
16721
16970
|
isParallel: step.parallelGroup !== void 0,
|
|
16722
16971
|
isConditional: false,
|
|
16723
16972
|
...step.arrow === "-x" ? { failed: true } : {},
|
|
16724
|
-
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)
|
|
16725
16977
|
});
|
|
16726
16978
|
}
|
|
16727
16979
|
return steps;
|
|
@@ -16887,6 +17139,16 @@ function parseStepLabel(input) {
|
|
|
16887
17139
|
const payload = splitPayload(input);
|
|
16888
17140
|
return { name: payload.text, format: payload.body ? payload.format : void 0, body: payload.body };
|
|
16889
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
|
+
}
|
|
16890
17152
|
function inferProtocol(isAsync, method, target) {
|
|
16891
17153
|
if (isAsync || target?.type === "queue") return "MESSAGING";
|
|
16892
17154
|
if (!method) return "OTHER";
|
|
@@ -17093,8 +17355,8 @@ function alignConnections(run2) {
|
|
|
17093
17355
|
function commentOf(raw, tokens) {
|
|
17094
17356
|
const last = tokens[tokens.length - 1];
|
|
17095
17357
|
if (last.kind === "label") {
|
|
17096
|
-
const
|
|
17097
|
-
return
|
|
17358
|
+
const after2 = raw.slice(last.col);
|
|
17359
|
+
return after2.slice(stripTrailingComment(after2).length).trim();
|
|
17098
17360
|
}
|
|
17099
17361
|
return raw.slice(last.col - 1 + last.length).trim();
|
|
17100
17362
|
}
|
|
@@ -17262,6 +17524,104 @@ var RESERVED = new Set(
|
|
|
17262
17524
|
].map((w) => w.toLowerCase())
|
|
17263
17525
|
);
|
|
17264
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
|
+
|
|
17265
17625
|
// ../frontend/src/sim/flow.ts
|
|
17266
17626
|
function findUseCase(diagram, name) {
|
|
17267
17627
|
const lower = name.toLowerCase();
|
|
@@ -17326,9 +17686,9 @@ function requestOrder(useCase, scenario) {
|
|
|
17326
17686
|
}
|
|
17327
17687
|
return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
|
|
17328
17688
|
}
|
|
17329
|
-
function writesBeforeResponse(useCase, scenario, accept) {
|
|
17689
|
+
function writesBeforeResponse(useCase, scenario, accept, isWrite = (step) => stepAccess(step) === "write") {
|
|
17330
17690
|
const { requests, beforeResponse } = requestOrder(useCase, scenario);
|
|
17331
|
-
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to));
|
|
17691
|
+
return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to) && isWrite(m.step));
|
|
17332
17692
|
}
|
|
17333
17693
|
function callsOk(scenario, nodeId) {
|
|
17334
17694
|
return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
|
|
@@ -17337,23 +17697,34 @@ function needs(useCase, nodeId) {
|
|
|
17337
17697
|
return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
|
|
17338
17698
|
}
|
|
17339
17699
|
function fallbacksFor(useCase, nodeId) {
|
|
17340
|
-
return useCase.scenarios.filter(
|
|
17341
|
-
(s
|
|
17342
|
-
|
|
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
|
+
});
|
|
17343
17707
|
}
|
|
17344
17708
|
function selectorText(selector) {
|
|
17345
17709
|
if ("node" in selector) return selector.node;
|
|
17346
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`;
|
|
17347
17713
|
return `any ${selector.kind}`;
|
|
17348
17714
|
}
|
|
17349
17715
|
function selectNodes(diagram, selector) {
|
|
17350
|
-
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector));
|
|
17716
|
+
return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector, diagram));
|
|
17351
17717
|
}
|
|
17352
|
-
function matches(node, selector) {
|
|
17718
|
+
function matches(node, selector, diagram) {
|
|
17353
17719
|
if (node.kind !== "component") return false;
|
|
17354
17720
|
if ("node" in selector) return node.id === selector.node;
|
|
17355
17721
|
if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
|
|
17356
|
-
|
|
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);
|
|
17357
17728
|
}
|
|
17358
17729
|
function techOf(node) {
|
|
17359
17730
|
return node && !node.implicit ? node.techStack : void 0;
|
|
@@ -17387,51 +17758,13 @@ function formatUsd(usd) {
|
|
|
17387
17758
|
return `$${Math.round(usd).toLocaleString("en-US")}/month`;
|
|
17388
17759
|
}
|
|
17389
17760
|
|
|
17390
|
-
// ../frontend/src/sim/profiles.ts
|
|
17391
|
-
var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
|
|
17392
|
-
var KIND_PROFILES = {
|
|
17393
|
-
client: NEUTRAL,
|
|
17394
|
-
edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
|
|
17395
|
-
service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
|
|
17396
|
-
function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
|
|
17397
|
-
cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false },
|
|
17398
|
-
database: { rps: 5e3, latencyMs: 5, availability: 0.9995, costUsd: 400, durable: true },
|
|
17399
|
-
search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true },
|
|
17400
|
-
analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true },
|
|
17401
|
-
queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true },
|
|
17402
|
-
storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true },
|
|
17403
|
-
external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
|
|
17404
|
-
other: NEUTRAL
|
|
17405
|
-
};
|
|
17406
|
-
var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true };
|
|
17407
|
-
var TECH_PROFILES = {
|
|
17408
|
-
DynamoDB: NOSQL,
|
|
17409
|
-
"AWS DynamoDB": NOSQL,
|
|
17410
|
-
Cassandra: NOSQL,
|
|
17411
|
-
MongoDB: NOSQL,
|
|
17412
|
-
CouchDB: NOSQL,
|
|
17413
|
-
"Azure Cosmos DB": NOSQL,
|
|
17414
|
-
"GCP Bigtable": NOSQL,
|
|
17415
|
-
"GCP Firestore": NOSQL,
|
|
17416
|
-
"GCP Spanner": NOSQL
|
|
17417
|
-
};
|
|
17418
|
-
function profileOf(node, override) {
|
|
17419
|
-
const kind = kindOf(node);
|
|
17420
|
-
const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
|
|
17421
|
-
return {
|
|
17422
|
-
kind,
|
|
17423
|
-
rps: override?.rps ?? base.rps,
|
|
17424
|
-
latencyMs: override?.latencyMs ?? base.latencyMs,
|
|
17425
|
-
availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
|
|
17426
|
-
costUsd: override?.costUsd ?? base.costUsd,
|
|
17427
|
-
durable: override?.durable ?? base.durable
|
|
17428
|
-
};
|
|
17429
|
-
}
|
|
17430
|
-
|
|
17431
17761
|
// ../frontend/src/sim/analyze.ts
|
|
17432
17762
|
var DEFAULT_TIMEOUT_MS = 1e3;
|
|
17433
17763
|
var HOT = 0.7;
|
|
17434
17764
|
var QUEUE_CAP = 0.95;
|
|
17765
|
+
var SECONDS_PER_MONTH = 2592e3;
|
|
17766
|
+
var BYTES_PER_MB = 1e6;
|
|
17767
|
+
var BYTES_PER_GB = 1e9;
|
|
17435
17768
|
var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
|
|
17436
17769
|
var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
|
|
17437
17770
|
var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
|
|
@@ -17467,20 +17800,73 @@ function resolveTraffic(diagram, warnings = []) {
|
|
|
17467
17800
|
}
|
|
17468
17801
|
return result;
|
|
17469
17802
|
}
|
|
17470
|
-
|
|
17471
|
-
|
|
17803
|
+
var multiplierOf = (step) => step.multiplier !== void 0 && step.multiplier > 0 ? step.multiplier : 1;
|
|
17804
|
+
function forEachStepRate(diagram, traffic, visit) {
|
|
17472
17805
|
for (const u of diagram.useCases) {
|
|
17473
17806
|
const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
|
|
17474
17807
|
u.scenarios.forEach((s, i) => {
|
|
17475
17808
|
const rate = rps * (shares[i] ?? 0);
|
|
17476
|
-
if (rate > 0) for (const step of s.steps)
|
|
17809
|
+
if (rate > 0) for (const step of s.steps) visit(step, rate);
|
|
17477
17810
|
});
|
|
17478
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
|
+
});
|
|
17479
17821
|
return load;
|
|
17480
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
|
+
}
|
|
17481
17839
|
var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
|
|
17482
17840
|
var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
|
|
17483
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;
|
|
17484
17870
|
function useCasePercentile(scenarios, q) {
|
|
17485
17871
|
const tail = 1 - q / 100 - 1e-9;
|
|
17486
17872
|
const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
|
|
@@ -17499,49 +17885,57 @@ function mainScenarioIndex(shares) {
|
|
|
17499
17885
|
});
|
|
17500
17886
|
return best;
|
|
17501
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
|
+
}
|
|
17502
17895
|
function analyze(diagram, options = {}) {
|
|
17503
17896
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
17504
17897
|
const warnings = [];
|
|
17505
17898
|
const nodes = components(diagram);
|
|
17506
|
-
const
|
|
17507
|
-
const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
|
|
17508
|
-
for (const o of diagram.capacity ?? []) if (!byId.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
|
|
17509
|
-
const profiles = new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
|
|
17899
|
+
const profiles = profilesOf(diagram, warnings);
|
|
17510
17900
|
const traffic = resolveTraffic(diagram, warnings);
|
|
17511
17901
|
const load = computeLoad(diagram, traffic);
|
|
17902
|
+
const egress = computeEgressGb(diagram, traffic);
|
|
17512
17903
|
const nodeResults = nodes.map((n) => {
|
|
17513
17904
|
const p = profiles.get(n.id);
|
|
17514
17905
|
const replicas = replicasOf(n);
|
|
17515
|
-
const
|
|
17516
|
-
const
|
|
17517
|
-
const
|
|
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;
|
|
17518
17910
|
return {
|
|
17519
17911
|
id: n.id,
|
|
17520
17912
|
kind: p.kind,
|
|
17521
17913
|
replicas,
|
|
17522
|
-
|
|
17523
|
-
|
|
17524
|
-
|
|
17525
|
-
|
|
17526
|
-
latencyMs: hopLatency(p.latencyMs, utilization),
|
|
17914
|
+
shards: p.shards,
|
|
17915
|
+
...usage,
|
|
17916
|
+
...p.consistency ? { consistency: p.consistency } : {},
|
|
17917
|
+
latencyMs: hopLatency(p.latencyMs, usage.utilization),
|
|
17527
17918
|
availability: replicatedAvailability(p.availability, replicas),
|
|
17528
|
-
costUsd: replicas * p.costUsd,
|
|
17919
|
+
costUsd: replicas * p.shards * p.costUsd + egressUsd,
|
|
17920
|
+
egressGbPerMonth,
|
|
17921
|
+
egressUsd,
|
|
17529
17922
|
durable: p.durable
|
|
17530
17923
|
};
|
|
17531
17924
|
});
|
|
17532
17925
|
const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
|
|
17533
17926
|
const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
|
|
17534
17927
|
const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
|
|
17928
|
+
const bandwidthOf = (id) => profiles.get(id)?.bandwidthMBps ?? Infinity;
|
|
17535
17929
|
for (const n of nodeResults) {
|
|
17536
|
-
const usage =
|
|
17537
|
-
if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. Add replicas or take load off it.`);
|
|
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.`);
|
|
17538
17932
|
else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
|
|
17539
17933
|
}
|
|
17540
17934
|
const useCases = diagram.useCases.map((u) => {
|
|
17541
17935
|
const { rps, shares } = traffic.get(u.id);
|
|
17542
17936
|
const scenarios = u.scenarios.map((s, i) => {
|
|
17543
17937
|
const meanMs = criticalPath(u, s).reduce(
|
|
17544
|
-
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target))),
|
|
17938
|
+
(sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target) + transferMs(hop.step, bandwidthOf))),
|
|
17545
17939
|
0
|
|
17546
17940
|
);
|
|
17547
17941
|
return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
|
|
@@ -17556,6 +17950,7 @@ function analyze(diagram, options = {}) {
|
|
|
17556
17950
|
nodes: nodeResults,
|
|
17557
17951
|
useCases,
|
|
17558
17952
|
totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
|
|
17953
|
+
totalEgressUsd: nodeResults.reduce((sum, n) => sum + n.egressUsd, 0),
|
|
17559
17954
|
singlePointsOfFailure: singlePointsOfFailure(diagram),
|
|
17560
17955
|
warnings
|
|
17561
17956
|
};
|
|
@@ -17580,7 +17975,7 @@ function useCaseAvailability(useCase, shares, availabilityOf) {
|
|
|
17580
17975
|
}
|
|
17581
17976
|
return result;
|
|
17582
17977
|
}
|
|
17583
|
-
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external";
|
|
17978
|
+
var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external" && kind !== "dns";
|
|
17584
17979
|
function singlePointsOfFailure(diagram) {
|
|
17585
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);
|
|
17586
17981
|
}
|
|
@@ -17589,20 +17984,20 @@ function singlePointsOfFailure(diagram) {
|
|
|
17589
17984
|
var pass = (message) => ({ passed: true, message });
|
|
17590
17985
|
var fail = (message, hint) => ({ passed: false, message, hint });
|
|
17591
17986
|
function runTests(diagram, analysis = analyze(diagram)) {
|
|
17987
|
+
const access = accessIn(diagram);
|
|
17592
17988
|
const run2 = {
|
|
17593
17989
|
diagram,
|
|
17594
17990
|
analysis,
|
|
17595
17991
|
nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
|
|
17596
17992
|
useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
|
|
17597
|
-
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares]))
|
|
17993
|
+
shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares])),
|
|
17994
|
+
profiles: profilesOf(diagram),
|
|
17995
|
+
isWrite: (step) => access(step) === "write"
|
|
17598
17996
|
};
|
|
17599
|
-
const results = (diagram.requirements ?? []).map((r, i) =>
|
|
17600
|
-
|
|
17601
|
-
name: requirementName(r),
|
|
17602
|
-
|
|
17603
|
-
...requirement(run2, r),
|
|
17604
|
-
loc: r.loc
|
|
17605
|
-
}));
|
|
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
|
+
});
|
|
17606
18001
|
const names = /* @__PURE__ */ new Map();
|
|
17607
18002
|
for (const test of diagram.tests ?? []) {
|
|
17608
18003
|
const n = (names.get(test.name) ?? 0) + 1;
|
|
@@ -17619,10 +18014,16 @@ var CATEGORY = {
|
|
|
17619
18014
|
cost: "cost"
|
|
17620
18015
|
};
|
|
17621
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
|
+
}
|
|
17622
18023
|
function requirementName(r) {
|
|
17623
18024
|
switch (r.kind) {
|
|
17624
18025
|
case "latency":
|
|
17625
|
-
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"} < ${formatMs(r.maxMs)}`;
|
|
18026
|
+
return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"}${r.scenario !== void 0 ? ` scenario ${r.scenario}` : ""} < ${formatMs(r.maxMs)}`;
|
|
17626
18027
|
case "availability":
|
|
17627
18028
|
return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
|
|
17628
18029
|
case "durable":
|
|
@@ -17636,6 +18037,7 @@ function requirementName(r) {
|
|
|
17636
18037
|
function requirement(run2, r) {
|
|
17637
18038
|
switch (r.kind) {
|
|
17638
18039
|
case "latency":
|
|
18040
|
+
if (r.useCase !== void 0 && r.scenario !== void 0) return scenarioLatency(run2, r.useCase, r.scenario, r.percentile, r.maxMs);
|
|
17639
18041
|
return overUseCases(run2, r.useCase, (u) => latency(run2, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
|
|
17640
18042
|
case "availability":
|
|
17641
18043
|
return overUseCases(run2, r.useCase, (u) => availability(run2, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
|
|
@@ -17664,43 +18066,65 @@ function overUseCases(run2, name, check, limit, anyWithoutTraffic = false) {
|
|
|
17664
18066
|
}
|
|
17665
18067
|
function unknownUseCase(run2, name) {
|
|
17666
18068
|
const known = run2.diagram.useCases.map((u) => `"${u.name}"`);
|
|
17667
|
-
return fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block');
|
|
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 };
|
|
17668
18076
|
}
|
|
17669
18077
|
function latency(run2, u, q, maxMs) {
|
|
17670
18078
|
const result = run2.useCases.get(u.id);
|
|
17671
18079
|
const key = percentileName(q);
|
|
17672
18080
|
const shares = run2.shares.get(u.id) ?? [];
|
|
17673
|
-
const touched =
|
|
17674
|
-
const saturated =
|
|
17675
|
-
if (saturated
|
|
17676
|
-
const n = saturated[0];
|
|
17677
|
-
const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
|
|
17678
|
-
const value2 = `${key} of ${u.name}: ${n.id} is saturated (${usage})`;
|
|
17679
|
-
return { ...fail(`${value2} (limit ${formatMs(maxMs)})`, saturationHint(run2, n)), value: value2 };
|
|
17680
|
-
}
|
|
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;
|
|
17681
18084
|
const ms = result.percentiles[key];
|
|
17682
18085
|
const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
|
|
17683
18086
|
const message = `${value} (limit ${formatMs(maxMs)})`;
|
|
17684
|
-
|
|
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));
|
|
17685
18104
|
}
|
|
17686
18105
|
function saturationHint(run2, n) {
|
|
17687
|
-
const
|
|
17688
|
-
|
|
17689
|
-
|
|
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)`;
|
|
17690
18115
|
}
|
|
17691
|
-
function latencyHint(run2, u,
|
|
17692
|
-
const index = result.scenarios.findIndex((s) => s.percentiles[key] === ms);
|
|
17693
|
-
const scenario = u.scenarios[Math.max(0, index)];
|
|
17694
|
-
const lead = u.scenarios.length > 1 ? `The "${scenario.name}" path (${formatPercent(result.scenarios[Math.max(0, index)].share)} of traffic) sets ${key}. ` : "";
|
|
18116
|
+
function latencyHint(run2, u, scenario) {
|
|
17695
18117
|
const hops = criticalPath(u, scenario).flat();
|
|
17696
18118
|
const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run2.nodes.get(h.target)?.latencyMs ?? 0;
|
|
17697
18119
|
const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
|
|
17698
|
-
if (!slowest) return
|
|
18120
|
+
if (!slowest) return "Shorten the synchronous path of the request.";
|
|
17699
18121
|
const label = nodeLabel(run2.diagram, slowest.target);
|
|
17700
|
-
if (slowest.failed) return
|
|
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);
|
|
17701
18124
|
const node = run2.nodes.get(slowest.target);
|
|
17702
|
-
if (node && node.utilization > HOT) return `${
|
|
17703
|
-
|
|
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}`;
|
|
17704
18128
|
}
|
|
17705
18129
|
function availability(run2, u, minPercent) {
|
|
17706
18130
|
const result = run2.useCases.get(u.id);
|
|
@@ -17713,37 +18137,53 @@ function availability(run2, u, minPercent) {
|
|
|
17713
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";
|
|
17714
18138
|
return { ...fail(message, hint), value };
|
|
17715
18139
|
}
|
|
17716
|
-
function writeGap(u, s, accept) {
|
|
18140
|
+
function writeGap(run2, u, s, accept) {
|
|
17717
18141
|
const { requests, beforeResponse } = requestOrder(u, s);
|
|
17718
|
-
const
|
|
17719
|
-
|
|
17720
|
-
|
|
17721
|
-
return "
|
|
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" };
|
|
17722
18151
|
}
|
|
17723
|
-
|
|
17724
|
-
async: "writes only asynchronously (->>)",
|
|
17725
|
-
after: "writes only after responding",
|
|
17726
|
-
none: "never writes"
|
|
17727
|
-
};
|
|
17728
|
-
function writesCheck(u, scenarios, accept, what) {
|
|
18152
|
+
function writesCheck(run2, u, scenarios, accept, what) {
|
|
17729
18153
|
const success = scenarios.filter((s) => s.outcome === "success");
|
|
17730
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");
|
|
17731
|
-
const gaps = success.
|
|
18155
|
+
const gaps = success.filter((s) => !writesBeforeResponse(u, s, accept, run2.isWrite));
|
|
17732
18156
|
if (gaps.length === 0) {
|
|
17733
18157
|
const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
|
|
17734
|
-
return pass(`${label} writes ${what} before responding`);
|
|
18158
|
+
return pass(`${label} writes to ${what} before responding`);
|
|
17735
18159
|
}
|
|
17736
|
-
const details = gaps.map((
|
|
18160
|
+
const details = gaps.map((s) => ({ s, gap: writeGap(run2, u, s, accept) }));
|
|
17737
18161
|
const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
|
|
17738
|
-
const
|
|
17739
|
-
|
|
17740
|
-
|
|
17741
|
-
|
|
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]);
|
|
17742
18182
|
}
|
|
17743
18183
|
function durable(run2, name) {
|
|
17744
18184
|
const u = findUseCase(run2.diagram, name);
|
|
17745
18185
|
if (!u) return unknownUseCase(run2, name);
|
|
17746
|
-
return writesCheck(u, u.scenarios, (id) => !!run2.nodes.get(id)?.durable, "
|
|
18186
|
+
return writesCheck(run2, u, u.scenarios, (id) => !!run2.nodes.get(id)?.durable, "a durable store");
|
|
17747
18187
|
}
|
|
17748
18188
|
function survive(run2, target) {
|
|
17749
18189
|
const all = components(run2.diagram);
|
|
@@ -17758,13 +18198,12 @@ function survive(run2, target) {
|
|
|
17758
18198
|
const label = nodeLabel(run2.diagram, node.id);
|
|
17759
18199
|
const replicas = replicasOf(node);
|
|
17760
18200
|
if (replicas >= 2) {
|
|
17761
|
-
const left =
|
|
17762
|
-
|
|
17763
|
-
if (rho >= 1) {
|
|
18201
|
+
const left = usageOf(run2.profiles.get(node.id), replicas - 1, n.readLoadRps, n.writeLoadRps);
|
|
18202
|
+
if (left.saturated) {
|
|
17764
18203
|
failures.push(
|
|
17765
18204
|
fail(
|
|
17766
|
-
`Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left)} for ${formatRps(n.loadRps)} (${formatPercent(
|
|
17767
|
-
`Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
|
|
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`
|
|
17768
18207
|
)
|
|
17769
18208
|
);
|
|
17770
18209
|
}
|
|
@@ -17789,20 +18228,34 @@ function survive(run2, target) {
|
|
|
17789
18228
|
return fail(`${failures[0].message}${more}`, failures[0].hint);
|
|
17790
18229
|
}
|
|
17791
18230
|
function cost(run2, max) {
|
|
17792
|
-
const total = run2.analysis
|
|
17793
|
-
const
|
|
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)})`;
|
|
17794
18234
|
if (total <= max) return pass(message);
|
|
17795
|
-
const
|
|
17796
|
-
|
|
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}`);
|
|
17797
18242
|
}
|
|
17798
18243
|
function flowTest(run2, test) {
|
|
17799
|
-
const
|
|
17800
|
-
const
|
|
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 });
|
|
17801
18246
|
if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
|
|
18247
|
+
const failed = checks.filter((a) => !a.passed);
|
|
17802
18248
|
if (failed.length === 0) {
|
|
17803
18249
|
return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
|
|
17804
18250
|
}
|
|
17805
|
-
|
|
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 };
|
|
17806
18259
|
}
|
|
17807
18260
|
var quote = (s) => `"${s}"`;
|
|
17808
18261
|
function scope(run2, useCase, scenario) {
|
|
@@ -17815,9 +18268,13 @@ function scope(run2, useCase, scenario) {
|
|
|
17815
18268
|
}
|
|
17816
18269
|
function missingScenario(u, scenario) {
|
|
17817
18270
|
const names = u.scenarios.map((s) => quote(s.name)).join(", ");
|
|
17818
|
-
return
|
|
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
|
+
};
|
|
17819
18275
|
}
|
|
17820
18276
|
var isCheck = (x) => "passed" in x;
|
|
18277
|
+
var idsOf = (run2, selector) => new Set(selectNodes(run2.diagram, selector).map((n) => n.id));
|
|
17821
18278
|
function assertion(run2, a) {
|
|
17822
18279
|
switch (a.kind) {
|
|
17823
18280
|
case "calls":
|
|
@@ -17827,9 +18284,9 @@ function assertion(run2, a) {
|
|
|
17827
18284
|
case "writesBeforeResponding": {
|
|
17828
18285
|
const sc = scope(run2, a.useCase, a.scenario);
|
|
17829
18286
|
if (isCheck(sc)) return sc;
|
|
17830
|
-
const ids =
|
|
18287
|
+
const ids = idsOf(run2, a.target);
|
|
17831
18288
|
if (ids.size === 0) return noMatch(a.target);
|
|
17832
|
-
return writesCheck(sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
18289
|
+
return writesCheck(run2, sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
|
|
17833
18290
|
}
|
|
17834
18291
|
case "responds":
|
|
17835
18292
|
return responds(run2, a);
|
|
@@ -17842,7 +18299,7 @@ function assertion(run2, a) {
|
|
|
17842
18299
|
case "handlesFailure": {
|
|
17843
18300
|
const u = findUseCase(run2.diagram, a.useCase);
|
|
17844
18301
|
if (!u) return unknownUseCase(run2, a.useCase);
|
|
17845
|
-
const ids =
|
|
18302
|
+
const ids = idsOf(run2, a.target);
|
|
17846
18303
|
if (ids.size === 0) return noMatch(a.target);
|
|
17847
18304
|
const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
|
|
17848
18305
|
const what = selectorText(a.target);
|
|
@@ -17861,6 +18318,14 @@ function assertion(run2, a) {
|
|
|
17861
18318
|
if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
|
|
17862
18319
|
return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
|
|
17863
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);
|
|
17864
18329
|
}
|
|
17865
18330
|
}
|
|
17866
18331
|
function noMatch(selector) {
|
|
@@ -17870,17 +18335,18 @@ function calls(run2, a) {
|
|
|
17870
18335
|
const sc = scope(run2, a.useCase, a.scenario);
|
|
17871
18336
|
if (isCheck(sc)) return sc;
|
|
17872
18337
|
const what = selectorText(a.target);
|
|
17873
|
-
const ids =
|
|
17874
|
-
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId))
|
|
18338
|
+
const ids = idsOf(run2, a.target);
|
|
18339
|
+
const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId));
|
|
17875
18340
|
const calling = sc.scenarios.filter((s) => called(s).length > 0);
|
|
17876
18341
|
const names = (list) => list.map((s) => quote(s.name)).join(", ");
|
|
17877
18342
|
const multi = sc.scenarios.length > 1;
|
|
17878
18343
|
if (a.quantifier === "never") {
|
|
17879
18344
|
if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
|
|
17880
18345
|
const first = calling[0];
|
|
18346
|
+
const step = called(first)[0];
|
|
17881
18347
|
return fail(
|
|
17882
|
-
`${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run2.diagram,
|
|
17883
|
-
`Take the call to ${
|
|
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`
|
|
17884
18350
|
);
|
|
17885
18351
|
}
|
|
17886
18352
|
if (ids.size === 0) return noMatch(a.target);
|
|
@@ -17895,22 +18361,22 @@ function calls(run2, a) {
|
|
|
17895
18361
|
function before(run2, a) {
|
|
17896
18362
|
const sc = scope(run2, a.useCase, a.scenario);
|
|
17897
18363
|
if (isCheck(sc)) return sc;
|
|
17898
|
-
const first =
|
|
17899
|
-
const then =
|
|
18364
|
+
const first = idsOf(run2, a.first);
|
|
18365
|
+
const then = idsOf(run2, a.then);
|
|
17900
18366
|
const [x, y] = [selectorText(a.first), selectorText(a.then)];
|
|
17901
18367
|
if (first.size === 0) return noMatch(a.first);
|
|
17902
18368
|
if (then.size === 0) return noMatch(a.then);
|
|
17903
18369
|
let checked = 0;
|
|
17904
18370
|
for (const s of sc.scenarios) {
|
|
17905
|
-
const order = requestOrder(sc.u, s).requests
|
|
17906
|
-
const iy = order.findIndex((
|
|
18371
|
+
const order = requestOrder(sc.u, s).requests;
|
|
18372
|
+
const iy = order.findIndex((m) => then.has(m.to));
|
|
17907
18373
|
if (iy < 0) continue;
|
|
17908
18374
|
checked++;
|
|
17909
|
-
const ix = order.findIndex((
|
|
18375
|
+
const ix = order.findIndex((m) => first.has(m.to));
|
|
17910
18376
|
if (ix < 0 || ix > iy) {
|
|
17911
18377
|
const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
|
|
17912
18378
|
return fail(
|
|
17913
|
-
ix < 0 ? `${where}, ${order[iy]} is called but ${x} never is` : `${where}, ${order[iy]} is called before ${order[ix]}`,
|
|
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)})`,
|
|
17914
18380
|
`Call ${x} before the first call to ${y}`
|
|
17915
18381
|
);
|
|
17916
18382
|
}
|
|
@@ -17918,6 +18384,100 @@ function before(run2, a) {
|
|
|
17918
18384
|
if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
|
|
17919
18385
|
return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
|
|
17920
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
|
+
}
|
|
17921
18481
|
function statusMatches(status, wanted) {
|
|
17922
18482
|
if (status === void 0) return false;
|
|
17923
18483
|
const cls = /^([1-5])xx$/i.exec(wanted);
|
|
@@ -17932,25 +18492,27 @@ function responds(run2, a) {
|
|
|
17932
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}`);
|
|
17933
18493
|
}
|
|
17934
18494
|
function noPath(run2, from, to) {
|
|
17935
|
-
const sources =
|
|
17936
|
-
const targets =
|
|
18495
|
+
const sources = idsOf(run2, from);
|
|
18496
|
+
const targets = idsOf(run2, to);
|
|
17937
18497
|
const [x, y] = [selectorText(from), selectorText(to)];
|
|
17938
|
-
const line = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
|
|
17939
18498
|
const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
|
|
17940
18499
|
const edge = run2.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
|
|
17941
|
-
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${
|
|
18500
|
+
if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${lineOf(edge.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
17942
18501
|
for (const u of run2.diagram.useCases) {
|
|
17943
18502
|
for (const s of u.scenarios) {
|
|
17944
18503
|
const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
|
|
17945
18504
|
if (step) {
|
|
17946
18505
|
const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
|
|
17947
|
-
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${
|
|
18506
|
+
return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${lineOf(step.loc)}) goes directly from ${x} to ${y}`, hint);
|
|
17948
18507
|
}
|
|
17949
18508
|
}
|
|
17950
18509
|
}
|
|
17951
18510
|
return pass(`No connection or step goes directly from ${x} to ${y}`);
|
|
17952
18511
|
}
|
|
17953
18512
|
|
|
18513
|
+
// ../frontend/src/hld/engine.ts
|
|
18514
|
+
var defaultEngine = { available: true, analyze: (d) => analyze(d), runTests: (d, a) => runTests(d, a) };
|
|
18515
|
+
|
|
17954
18516
|
// src/fmt.ts
|
|
17955
18517
|
var FMT_USAGE = ` proschi fmt [--check] <file|dir>...`;
|
|
17956
18518
|
var FMT_HELP = `fmt Rewrites files in the canonical layout and lists the ones it changed.
|
|
@@ -18002,8 +18564,9 @@ var SIM_USAGE = ` proschi test [--format text|github|json] <file|dir>...
|
|
|
18002
18564
|
var SIM_HELP = `test Runs the requirements and test blocks of each file (imports are
|
|
18003
18565
|
followed) against the simulation. Exits with 1 if any fails or a file
|
|
18004
18566
|
has errors.
|
|
18005
|
-
analyze Prints the capacity table: load
|
|
18006
|
-
and cost per node; latency
|
|
18567
|
+
analyze Prints the capacity table: reads and writes (load of capacity and
|
|
18568
|
+
utilisation), latency, availability, egress and cost per node; latency
|
|
18569
|
+
percentiles per use case and scenario.`;
|
|
18007
18570
|
function testFiles(files, load = parseFile) {
|
|
18008
18571
|
return files.map((file) => {
|
|
18009
18572
|
const { diagram, diagnostics } = load(file);
|
|
@@ -18111,27 +18674,38 @@ function table(rows, left = 1) {
|
|
|
18111
18674
|
const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => r[c].length)));
|
|
18112
18675
|
return rows.map((r) => r.map((cell, c) => c < left ? cell.padEnd(widths[c]) : cell.padStart(widths[c])).join(" ").trimEnd());
|
|
18113
18676
|
}
|
|
18677
|
+
function accessCell(load, capacity, utilization) {
|
|
18678
|
+
return load > 0 ? `${formatRps(load).replace(" rps", "")}/${formatRps(capacity)} ${formatPercent(utilization)}` : "-";
|
|
18679
|
+
}
|
|
18680
|
+
function formatGb(gb) {
|
|
18681
|
+
const n = (x, digits) => Number(x.toFixed(digits)).toLocaleString("en-US");
|
|
18682
|
+
return gb >= 1e3 ? `${n(gb / 1e3, 1)} TB` : `${n(gb, gb < 10 ? 1 : 0)} GB`;
|
|
18683
|
+
}
|
|
18684
|
+
function egressCell(n) {
|
|
18685
|
+
return n.egressGbPerMonth > 0 ? `${formatUsd(n.egressUsd).replace("/month", "")} (${formatGb(n.egressGbPerMonth)})` : "-";
|
|
18686
|
+
}
|
|
18114
18687
|
function capacityReport(diagram, analysis, file) {
|
|
18115
18688
|
const lines = [diagram.title ? `${diagram.title} (${file})` : file, ""];
|
|
18116
18689
|
if ((diagram.traffic ?? []).length === 0) lines.push("No traffic { \u2026 } block: loads are zero and latencies are at idle.", "");
|
|
18117
18690
|
const nodes = analysis.nodes.filter((n) => n.kind !== "client" && n.kind !== "other");
|
|
18118
18691
|
lines.push(
|
|
18119
18692
|
...table([
|
|
18120
|
-
["Node", "Kind", "Replicas", "
|
|
18693
|
+
["Node", "Kind", "Replicas", "Reads", "Writes", "Util", "Latency", "Availability", "Egress/month", "Cost/month"],
|
|
18121
18694
|
...nodes.map((n) => [
|
|
18122
18695
|
n.id,
|
|
18123
18696
|
n.kind,
|
|
18124
|
-
String(n.replicas),
|
|
18125
|
-
|
|
18126
|
-
|
|
18697
|
+
n.shards > 1 ? `${n.replicas}x${n.shards} shards` : String(n.replicas),
|
|
18698
|
+
accessCell(n.readLoadRps, n.readCapacityRps, n.readUtilization),
|
|
18699
|
+
accessCell(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization),
|
|
18127
18700
|
`${formatPercent(n.utilization)}${n.saturated ? " SATURATED" : n.utilization > HOT ? " hot" : ""}`,
|
|
18128
18701
|
formatMs(n.latencyMs),
|
|
18129
18702
|
formatAvailability(n.availability),
|
|
18703
|
+
egressCell(n),
|
|
18130
18704
|
formatUsd(n.costUsd).replace("/month", "")
|
|
18131
18705
|
])
|
|
18132
18706
|
], 2),
|
|
18133
18707
|
"",
|
|
18134
|
-
`Total cost: ${formatUsd(analysis.totalCostUsd)}`,
|
|
18708
|
+
`Total cost: ${formatUsd(analysis.totalCostUsd)}${analysis.totalEgressUsd > 0 ? ` (${formatUsd(analysis.totalEgressUsd)} of it egress)` : ""}`,
|
|
18135
18709
|
""
|
|
18136
18710
|
);
|
|
18137
18711
|
if (analysis.useCases.length) {
|
|
@@ -18184,14 +18758,700 @@ function checkFiles(files, extra) {
|
|
|
18184
18758
|
return [...byFile].map(([file, diagnostics]) => ({ file, diagnostics: diagnostics.sort((x, y) => x.line - y.line || x.col - y.col) }));
|
|
18185
18759
|
}
|
|
18186
18760
|
|
|
18761
|
+
// src/problem.ts
|
|
18762
|
+
var import_node_fs5 = require("node:fs");
|
|
18763
|
+
var import_node_path6 = require("node:path");
|
|
18764
|
+
|
|
18765
|
+
// ../frontend/src/practice/frontMatter.ts
|
|
18766
|
+
var FrontMatterError = class extends Error {
|
|
18767
|
+
/** 1-based line in the file. */
|
|
18768
|
+
line;
|
|
18769
|
+
constructor(message, line) {
|
|
18770
|
+
super(`line ${line}: ${message}`);
|
|
18771
|
+
this.name = "FrontMatterError";
|
|
18772
|
+
this.line = line;
|
|
18773
|
+
}
|
|
18774
|
+
};
|
|
18775
|
+
var KEY = /^([A-Za-z][A-Za-z0-9_-]*):(?:[ \t]+(.*))?$/;
|
|
18776
|
+
var NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
18777
|
+
var AMBIGUOUS = /^(?:true|false|yes|no|on|off|null|~)$/i;
|
|
18778
|
+
var INDICATORS = "-?:,[]{}#&*!|>'\"%@`";
|
|
18779
|
+
function isPlainSafe(s, inFlow = false) {
|
|
18780
|
+
if (s === "" || s !== s.trim() || /[\n\r\t]/.test(s)) return false;
|
|
18781
|
+
if (INDICATORS.includes(s[0]) || s.includes(": ") || s.includes(" #") || s.endsWith(":")) return false;
|
|
18782
|
+
if (NUMBER.test(s) || AMBIGUOUS.test(s)) return false;
|
|
18783
|
+
return !(inFlow && /[,[\]{}]/.test(s));
|
|
18784
|
+
}
|
|
18785
|
+
function scalar(text, line, inFlow) {
|
|
18786
|
+
if (text.startsWith('"')) {
|
|
18787
|
+
if (!/^"(?:[^"\\]|\\.)*"$/.test(text)) throw new FrontMatterError(`Unterminated or malformed double-quoted string: ${text}`, line);
|
|
18788
|
+
try {
|
|
18789
|
+
return JSON.parse(text);
|
|
18790
|
+
} catch {
|
|
18791
|
+
throw new FrontMatterError(`Invalid escape in double-quoted string: ${text}`, line);
|
|
18792
|
+
}
|
|
18793
|
+
}
|
|
18794
|
+
if (text.startsWith("'")) {
|
|
18795
|
+
if (!/^'(?:[^']|'')*'$/.test(text)) throw new FrontMatterError(`Unterminated or malformed single-quoted string: ${text}`, line);
|
|
18796
|
+
return text.slice(1, -1).replace(/''/g, "'");
|
|
18797
|
+
}
|
|
18798
|
+
if (NUMBER.test(text)) return Number(text);
|
|
18799
|
+
if (!isPlainSafe(text, inFlow)) throw new FrontMatterError(`Ambiguous value ${JSON.stringify(text)}; put it in double quotes`, line);
|
|
18800
|
+
return text;
|
|
18801
|
+
}
|
|
18802
|
+
function stringItem(text, line, inFlow) {
|
|
18803
|
+
const value = scalar(text, line, inFlow);
|
|
18804
|
+
if (typeof value !== "string") throw new FrontMatterError(`List items must be strings; quote ${text}`, line);
|
|
18805
|
+
return value;
|
|
18806
|
+
}
|
|
18807
|
+
function flowItems(inner, line) {
|
|
18808
|
+
const items = [];
|
|
18809
|
+
let current = "";
|
|
18810
|
+
let quote3;
|
|
18811
|
+
for (let i = 0; i < inner.length; i++) {
|
|
18812
|
+
const c = inner[i];
|
|
18813
|
+
if (quote3) {
|
|
18814
|
+
current += c;
|
|
18815
|
+
if (quote3 === '"' && c === "\\") current += inner[++i] ?? "";
|
|
18816
|
+
else if (c === quote3) {
|
|
18817
|
+
if (quote3 === "'" && inner[i + 1] === "'") current += inner[++i];
|
|
18818
|
+
else quote3 = void 0;
|
|
18819
|
+
}
|
|
18820
|
+
} else if (c === '"' || c === "'") {
|
|
18821
|
+
quote3 = c;
|
|
18822
|
+
current += c;
|
|
18823
|
+
} else if (c === ",") {
|
|
18824
|
+
items.push(current.trim());
|
|
18825
|
+
current = "";
|
|
18826
|
+
} else current += c;
|
|
18827
|
+
}
|
|
18828
|
+
if (quote3) throw new FrontMatterError("Unterminated string in list", line);
|
|
18829
|
+
if (current.trim() !== "" || items.length > 0) items.push(current.trim());
|
|
18830
|
+
if (items.some((item) => item === "")) throw new FrontMatterError("Empty item in list", line);
|
|
18831
|
+
return items.map((item) => stringItem(item, line, true));
|
|
18832
|
+
}
|
|
18833
|
+
function parseFrontMatter(text) {
|
|
18834
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
18835
|
+
if (lines[0] !== "---") throw new FrontMatterError('The file must start with a line "---"', 1);
|
|
18836
|
+
const end = lines.indexOf("---", 1);
|
|
18837
|
+
if (end < 0) throw new FrontMatterError('No closing "---" line after the front matter', 1);
|
|
18838
|
+
const data = {};
|
|
18839
|
+
let list;
|
|
18840
|
+
for (let i = 1; i < end; i++) {
|
|
18841
|
+
const raw = lines[i];
|
|
18842
|
+
const line = i + 1;
|
|
18843
|
+
if (raw.trim() === "" || /^\s*#/.test(raw)) continue;
|
|
18844
|
+
if (/\t/.test(raw.match(/^\s*/)[0])) throw new FrontMatterError("Indent with spaces, not tabs", line);
|
|
18845
|
+
const item = raw.match(/^ +- (.*)$/) ?? raw.match(/^ +-()$/);
|
|
18846
|
+
if (item) {
|
|
18847
|
+
if (!list) throw new FrontMatterError('A list item must follow a key with no value, e.g. "hints:"', line);
|
|
18848
|
+
const value2 = item[1].trim();
|
|
18849
|
+
if (value2 === "") throw new FrontMatterError("Empty list item", line);
|
|
18850
|
+
list.items.push(stringItem(value2, line, false));
|
|
18851
|
+
continue;
|
|
18852
|
+
}
|
|
18853
|
+
if (/^\s/.test(raw)) throw new FrontMatterError(`Unexpected indented line: ${raw.trim()}`, line);
|
|
18854
|
+
list = void 0;
|
|
18855
|
+
const m = raw.match(KEY);
|
|
18856
|
+
if (!m) throw new FrontMatterError(`Expected "key: value", got: ${raw}`, line);
|
|
18857
|
+
const [, key, rest] = m;
|
|
18858
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) throw new FrontMatterError(`Duplicate key '${key}'`, line);
|
|
18859
|
+
const value = (rest ?? "").trim();
|
|
18860
|
+
if (value === "") {
|
|
18861
|
+
list = { key, items: [] };
|
|
18862
|
+
data[key] = list.items;
|
|
18863
|
+
} else if (value.startsWith("[")) {
|
|
18864
|
+
if (!value.endsWith("]")) throw new FrontMatterError(`A list must close on the same line: ${value}`, line);
|
|
18865
|
+
data[key] = flowItems(value.slice(1, -1), line);
|
|
18866
|
+
} else {
|
|
18867
|
+
data[key] = scalar(value, line, false);
|
|
18868
|
+
}
|
|
18869
|
+
}
|
|
18870
|
+
for (const [key, value] of Object.entries(data)) {
|
|
18871
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
18872
|
+
const line = lines.findIndex((l, i) => i > 0 && i < end && l.startsWith(`${key}:`)) + 1;
|
|
18873
|
+
throw new FrontMatterError(`'${key}' has no value`, line);
|
|
18874
|
+
}
|
|
18875
|
+
}
|
|
18876
|
+
return { data, body: lines.slice(end + 1).join("\n").trim() };
|
|
18877
|
+
}
|
|
18878
|
+
|
|
18879
|
+
// ../frontend/src/practice/types.ts
|
|
18880
|
+
var DIFFICULTIES = ["easy", "medium", "hard"];
|
|
18881
|
+
|
|
18882
|
+
// ../frontend/src/practice/problemFiles.ts
|
|
18883
|
+
var PROBLEM_MD = "problem.md";
|
|
18884
|
+
var GIVEN = "given.proschi";
|
|
18885
|
+
var STARTER = "starter.proschi";
|
|
18886
|
+
var SOLUTION = "solution.proschi";
|
|
18887
|
+
var WRONG_DIR = "wrong";
|
|
18888
|
+
var REQUIRED = [PROBLEM_MD, GIVEN, STARTER, SOLUTION];
|
|
18889
|
+
var ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
18890
|
+
var EXPECT_FAIL = /^#\s*expect-fail:\s*(.*?)\s*$/;
|
|
18891
|
+
var ProblemFolderError = class extends Error {
|
|
18892
|
+
folder;
|
|
18893
|
+
file;
|
|
18894
|
+
line;
|
|
18895
|
+
/** The message without the folder and file. */
|
|
18896
|
+
detail;
|
|
18897
|
+
constructor(folder, message, file, line) {
|
|
18898
|
+
super(`problems/${folder}: ${file ? `${file}${line ? `:${line}` : ""}: ` : ""}${message}`);
|
|
18899
|
+
this.name = "ProblemFolderError";
|
|
18900
|
+
this.detail = message;
|
|
18901
|
+
this.folder = folder;
|
|
18902
|
+
this.file = file;
|
|
18903
|
+
this.line = line;
|
|
18904
|
+
}
|
|
18905
|
+
};
|
|
18906
|
+
function expectFailLines(source2) {
|
|
18907
|
+
const names = [];
|
|
18908
|
+
for (const line of source2.split("\n")) {
|
|
18909
|
+
if (!line.startsWith("#")) break;
|
|
18910
|
+
const m = line.match(EXPECT_FAIL);
|
|
18911
|
+
if (m) names.push(m[1]);
|
|
18912
|
+
}
|
|
18913
|
+
return names;
|
|
18914
|
+
}
|
|
18915
|
+
var FIELDS = ["title", "summary", "difficulty", "tags", "order", "hints"];
|
|
18916
|
+
function readProblemMd(id, text) {
|
|
18917
|
+
const fail2 = (message, line) => {
|
|
18918
|
+
throw new ProblemFolderError(id, message, PROBLEM_MD, line);
|
|
18919
|
+
};
|
|
18920
|
+
let data;
|
|
18921
|
+
let body;
|
|
18922
|
+
try {
|
|
18923
|
+
({ data, body } = parseFrontMatter(text));
|
|
18924
|
+
} catch (e) {
|
|
18925
|
+
if (e instanceof FrontMatterError) return fail2(e.message.replace(/^line \d+: /, ""), e.line);
|
|
18926
|
+
throw e;
|
|
18927
|
+
}
|
|
18928
|
+
for (const key of Object.keys(data)) if (!FIELDS.includes(key)) fail2(`Unknown front matter field '${key}' (use ${FIELDS.join(", ")})`);
|
|
18929
|
+
const str = (key) => {
|
|
18930
|
+
const v = data[key];
|
|
18931
|
+
if (typeof v !== "string" || v.trim() === "") return fail2(`'${key}' must be a non-empty string`);
|
|
18932
|
+
return v;
|
|
18933
|
+
};
|
|
18934
|
+
const list = (key) => {
|
|
18935
|
+
const v = data[key];
|
|
18936
|
+
if (!Array.isArray(v) || v.length === 0) return fail2(`'${key}' must be a list with at least one item`);
|
|
18937
|
+
return v;
|
|
18938
|
+
};
|
|
18939
|
+
const difficulty = str("difficulty");
|
|
18940
|
+
if (!DIFFICULTIES.includes(difficulty)) fail2(`'difficulty' must be one of ${DIFFICULTIES.join(", ")}`);
|
|
18941
|
+
const order = data.order;
|
|
18942
|
+
if (order !== void 0 && typeof order !== "number") fail2("'order' must be a number");
|
|
18943
|
+
if (body === "") fail2("The statement (the Markdown after the front matter) is empty");
|
|
18944
|
+
return {
|
|
18945
|
+
title: str("title"),
|
|
18946
|
+
summary: str("summary"),
|
|
18947
|
+
difficulty,
|
|
18948
|
+
tags: list("tags"),
|
|
18949
|
+
...typeof order === "number" ? { order } : {},
|
|
18950
|
+
statement: body,
|
|
18951
|
+
hints: list("hints")
|
|
18952
|
+
};
|
|
18953
|
+
}
|
|
18954
|
+
function problemFromFiles(id, files) {
|
|
18955
|
+
const fail2 = (message) => {
|
|
18956
|
+
throw new ProblemFolderError(id, message);
|
|
18957
|
+
};
|
|
18958
|
+
if (!ID.test(id)) fail2('The folder name is the problem id and must be lowercase words joined by "-", e.g. url-shortener');
|
|
18959
|
+
for (const name of REQUIRED) if (files[name] === void 0) fail2(`Missing ${name}`);
|
|
18960
|
+
for (const name of Object.keys(files)) {
|
|
18961
|
+
const wrong2 = name.startsWith(`${WRONG_DIR}/`) ? name.slice(WRONG_DIR.length + 1) : void 0;
|
|
18962
|
+
if (!REQUIRED.includes(name) && !(wrong2 && /^[a-z0-9][a-z0-9-]*\.proschi$/.test(wrong2))) {
|
|
18963
|
+
fail2(`Unexpected file ${name}: a problem folder holds ${REQUIRED.join(", ")} and ${WRONG_DIR}/<name>.proschi (lowercase, digits and "-")`);
|
|
18964
|
+
}
|
|
18965
|
+
}
|
|
18966
|
+
const meta = readProblemMd(id, files[PROBLEM_MD]);
|
|
18967
|
+
const wrong = Object.keys(files).filter((name) => name.startsWith(`${WRONG_DIR}/`)).sort().map((name) => ({ name: name.slice(WRONG_DIR.length + 1, -".proschi".length), source: files[name], expectFail: expectFailLines(files[name]) }));
|
|
18968
|
+
return {
|
|
18969
|
+
id,
|
|
18970
|
+
...meta,
|
|
18971
|
+
given: files[GIVEN],
|
|
18972
|
+
starter: files[STARTER],
|
|
18973
|
+
solution: files[SOLUTION],
|
|
18974
|
+
...wrong.length ? { wrong } : {}
|
|
18975
|
+
};
|
|
18976
|
+
}
|
|
18977
|
+
|
|
18978
|
+
// ../frontend/src/dsl/imports.ts
|
|
18979
|
+
function joinImportPath(fromPath, importPath) {
|
|
18980
|
+
const parts = importPath.startsWith("/") || !fromPath ? [] : fromPath.split("/").slice(0, -1);
|
|
18981
|
+
for (const part of importPath.split("/")) {
|
|
18982
|
+
if (part === "" || part === ".") continue;
|
|
18983
|
+
if (part === ".." && parts.length > 0 && parts[parts.length - 1] !== "..") parts.pop();
|
|
18984
|
+
else parts.push(part);
|
|
18985
|
+
}
|
|
18986
|
+
return parts.join("/");
|
|
18987
|
+
}
|
|
18988
|
+
function mapResolver(files) {
|
|
18989
|
+
const paths = Object.keys(files);
|
|
18990
|
+
return (importPath, fromPath) => {
|
|
18991
|
+
const path = joinImportPath(fromPath, importPath);
|
|
18992
|
+
if (Object.prototype.hasOwnProperty.call(files, path)) return { path, source: files[path] };
|
|
18993
|
+
const name = path.split("/").pop();
|
|
18994
|
+
const sameName = paths.filter((p) => p.split("/").pop() === name);
|
|
18995
|
+
return sameName.length === 1 ? { path: sameName[0], source: files[sameName[0]] } : void 0;
|
|
18996
|
+
};
|
|
18997
|
+
}
|
|
18998
|
+
|
|
18999
|
+
// ../frontend/src/playground/imports.ts
|
|
19000
|
+
function filesResolver(files, rootPath) {
|
|
19001
|
+
const resolve4 = mapResolver(files);
|
|
19002
|
+
return (importPath, fromPath) => {
|
|
19003
|
+
if (joinImportPath(fromPath, importPath) === rootPath) return { path: rootPath, source: "" };
|
|
19004
|
+
return resolve4(importPath, fromPath);
|
|
19005
|
+
};
|
|
19006
|
+
}
|
|
19007
|
+
|
|
19008
|
+
// ../frontend/src/practice/workspace.ts
|
|
19009
|
+
var PROBLEM_FILE = "problem.proschi";
|
|
19010
|
+
var SOLUTION_FILE = "solution.proschi";
|
|
19011
|
+
var CAPACITY_MESSAGE = "capacity is set by the problem; change the design (replicas, shards, caching) instead";
|
|
19012
|
+
function problemResolver(problem) {
|
|
19013
|
+
return filesResolver({ [PROBLEM_FILE]: problem.given }, SOLUTION_FILE);
|
|
19014
|
+
}
|
|
19015
|
+
function enforceProblemCapacity(result) {
|
|
19016
|
+
const capacity = result.diagram.capacity;
|
|
19017
|
+
if (!capacity?.some((c) => c.loc.file !== PROBLEM_FILE)) return result;
|
|
19018
|
+
const kept = [];
|
|
19019
|
+
const errors = [];
|
|
19020
|
+
for (const c of capacity) {
|
|
19021
|
+
if (c.loc.file === PROBLEM_FILE) {
|
|
19022
|
+
kept.push(c);
|
|
19023
|
+
continue;
|
|
19024
|
+
}
|
|
19025
|
+
const { node, loc, shards, ...rest } = c;
|
|
19026
|
+
if (Object.keys(rest).length > 0) {
|
|
19027
|
+
errors.push({ severity: "error", message: `${CAPACITY_MESSAGE} (only \`shards <n>\` may be set in your file)`, ...loc });
|
|
19028
|
+
}
|
|
19029
|
+
if (shards !== void 0) kept.push({ node, shards, loc });
|
|
19030
|
+
}
|
|
19031
|
+
const diagram = { ...result.diagram };
|
|
19032
|
+
if (kept.length) diagram.capacity = kept;
|
|
19033
|
+
else delete diagram.capacity;
|
|
19034
|
+
return { ...result, diagram, diagnostics: [...result.diagnostics, ...errors] };
|
|
19035
|
+
}
|
|
19036
|
+
function parseSolution(problem, source2) {
|
|
19037
|
+
return enforceProblemCapacity(parse(source2, { path: SOLUTION_FILE, resolve: problemResolver(problem) }));
|
|
19038
|
+
}
|
|
19039
|
+
function runTests2(parsed, engine) {
|
|
19040
|
+
if (!engine.available) return { blocked: "no-engine", results: [], passed: 0, solved: false };
|
|
19041
|
+
if (parsed.diagnostics.some((d) => d.severity === "error")) return { blocked: "errors", results: [], passed: 0, solved: false };
|
|
19042
|
+
const analysis = engine.analyze(parsed.diagram);
|
|
19043
|
+
const results = engine.runTests(parsed.diagram, analysis);
|
|
19044
|
+
const passed = results.filter((r) => r.passed).length;
|
|
19045
|
+
return { results, passed, solved: results.length > 0 && passed === results.length };
|
|
19046
|
+
}
|
|
19047
|
+
|
|
19048
|
+
// ../frontend/src/practice/validate.ts
|
|
19049
|
+
var IMPORT_LINE = `import "${PROBLEM_FILE}"`;
|
|
19050
|
+
function trafficUseCases(given) {
|
|
19051
|
+
return [...(given.match(/traffic\s*\{([\s\S]*?)\}/)?.[1] ?? "").matchAll(/^\s*"([^"]+)"/gm)].map((m) => m[1]);
|
|
19052
|
+
}
|
|
19053
|
+
function firstCodeLine(source2) {
|
|
19054
|
+
const lines = source2.split("\n");
|
|
19055
|
+
const i = lines.findIndex((l) => !l.startsWith("#"));
|
|
19056
|
+
return i < 0 ? { text: "", line: lines.length } : { text: lines[i], line: i + 1 };
|
|
19057
|
+
}
|
|
19058
|
+
function validateProblem(problem, engine = defaultEngine) {
|
|
19059
|
+
const violations = [];
|
|
19060
|
+
const add = (file, message, line) => violations.push({ file, message, ...line ? { line } : {} });
|
|
19061
|
+
const fileOf = (d, own) => d.file === PROBLEM_FILE ? GIVEN : own;
|
|
19062
|
+
const report = (own, ds) => {
|
|
19063
|
+
for (const d of ds) add(fileOf(d, own), `${d.severity}: ${d.message}`, d.line);
|
|
19064
|
+
};
|
|
19065
|
+
if (!/^## Functional requirements$/m.test(problem.statement)) add(PROBLEM_MD, 'The statement needs a "## Functional requirements" section');
|
|
19066
|
+
const useCases = trafficUseCases(problem.given);
|
|
19067
|
+
if (useCases.length === 0) add(GIVEN, "The given needs a traffic block naming at least one use case");
|
|
19068
|
+
for (const name of useCases) {
|
|
19069
|
+
if (!problem.statement.includes(`**${name}**`)) add(PROBLEM_MD, `The statement must name the use case **${name}** in bold`);
|
|
19070
|
+
if (!problem.solution.includes(`usecase "${name}"`)) add(SOLUTION, `The solution must define usecase "${name}" from the traffic`);
|
|
19071
|
+
}
|
|
19072
|
+
const importLine = problem.given.split("\n").findIndex((l) => /^\s*import /.test(l));
|
|
19073
|
+
if (importLine >= 0) add(GIVEN, "The given must not import anything", importLine + 1);
|
|
19074
|
+
report(GIVEN, parse(problem.given).diagnostics.filter((d) => d.severity === "error"));
|
|
19075
|
+
if (format(problem.given) !== problem.given) add(GIVEN, "Not in canonical format; run proschi fmt");
|
|
19076
|
+
if (!problem.solution.startsWith(`${IMPORT_LINE}
|
|
19077
|
+
`)) add(SOLUTION, `Must start with ${IMPORT_LINE}`, 1);
|
|
19078
|
+
const solution = parseSolution(problem, problem.solution);
|
|
19079
|
+
report(SOLUTION, solution.diagnostics);
|
|
19080
|
+
if (format(problem.solution) !== problem.solution) add(SOLUTION, "Not in canonical format; run proschi fmt");
|
|
19081
|
+
const solutionRun = runTests2(solution, engine);
|
|
19082
|
+
const testNames = new Set(solutionRun.results.map((r) => r.name));
|
|
19083
|
+
if (solutionRun.blocked === "no-engine") add(SOLUTION, "No simulation to run the tests with");
|
|
19084
|
+
else if (solutionRun.blocked !== "errors") {
|
|
19085
|
+
if (solutionRun.results.length === 0) add(GIVEN, "The problem has no requirements or tests");
|
|
19086
|
+
for (const r of solutionRun.results.filter((r2) => !r2.passed)) add(SOLUTION, `Fails "${r.name}": ${r.message}`, r.loc?.file === void 0 ? r.loc?.line : void 0);
|
|
19087
|
+
}
|
|
19088
|
+
if (!problem.starter.startsWith(`${IMPORT_LINE}
|
|
19089
|
+
`)) add(STARTER, `Must start with ${IMPORT_LINE}`, 1);
|
|
19090
|
+
const starter = parseSolution(problem, problem.starter);
|
|
19091
|
+
report(STARTER, starter.diagnostics.filter((d) => d.severity === "error"));
|
|
19092
|
+
const starterRun = runTests2(starter, engine);
|
|
19093
|
+
const starterFails = starterRun.results.filter((r) => !r.passed).map((r) => r.name);
|
|
19094
|
+
if (!starterRun.blocked && starterFails.length === 0) add(STARTER, "The starter passes every test; it must leave something to solve");
|
|
19095
|
+
const wrong = (problem.wrong ?? []).map((w) => {
|
|
19096
|
+
const file = `${WRONG_DIR}/${w.name}.proschi`;
|
|
19097
|
+
const expectFail = w.expectFail.length ? w.expectFail : expectFailLines(w.source);
|
|
19098
|
+
if (expectFail.length === 0) add(file, 'Start the file with one or more "# expect-fail: <test name>" lines', 1);
|
|
19099
|
+
const code = firstCodeLine(w.source);
|
|
19100
|
+
if (code.text !== IMPORT_LINE) add(file, `After the comment lines the file must start with ${IMPORT_LINE}`, code.line);
|
|
19101
|
+
const parsed = parseSolution(problem, w.source);
|
|
19102
|
+
report(file, parsed.diagnostics.filter((d) => d.severity === "error"));
|
|
19103
|
+
const run2 = runTests2(parsed, engine);
|
|
19104
|
+
const failed = run2.results.filter((r) => !r.passed).map((r) => r.name);
|
|
19105
|
+
const missing = [];
|
|
19106
|
+
if (!run2.blocked) {
|
|
19107
|
+
const lines = w.source.split("\n");
|
|
19108
|
+
for (const name of expectFail) {
|
|
19109
|
+
const line = lines.findIndex((l) => l.startsWith("#") && l.includes(name)) + 1 || void 0;
|
|
19110
|
+
if (!testNames.has(name) && !failed.includes(name)) {
|
|
19111
|
+
add(file, `No test or requirement is named "${name}" (names: ${[...testNames].map((n) => JSON.stringify(n)).join(", ")})`, line);
|
|
19112
|
+
missing.push(name);
|
|
19113
|
+
} else if (!failed.includes(name)) {
|
|
19114
|
+
add(file, `Expected to fail "${name}", but it passes`, line);
|
|
19115
|
+
missing.push(name);
|
|
19116
|
+
}
|
|
19117
|
+
}
|
|
19118
|
+
}
|
|
19119
|
+
return { name: w.name, file, expectFail, failed, missing, alsoFails: failed.filter((n) => !expectFail.includes(n)) };
|
|
19120
|
+
});
|
|
19121
|
+
return { id: problem.id, tests: solutionRun.results.length, starterFails, wrong, violations };
|
|
19122
|
+
}
|
|
19123
|
+
|
|
19124
|
+
// src/problemTemplate.ts
|
|
19125
|
+
function problemTemplate(id) {
|
|
19126
|
+
const title = id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
19127
|
+
return {
|
|
19128
|
+
"problem.md": `---
|
|
19129
|
+
title: ${title}
|
|
19130
|
+
summary: TODO One line for the problem list and the landing page.
|
|
19131
|
+
difficulty: easy
|
|
19132
|
+
tags: [caching, todo]
|
|
19133
|
+
hints:
|
|
19134
|
+
- TODO A nudge, e.g. what answers most reads without the database?
|
|
19135
|
+
- TODO Closer, e.g. a miss reads the database, then fills the cache.
|
|
19136
|
+
- TODO Nearly the answer, e.g. two of everything survives any node failure.
|
|
19137
|
+
---
|
|
19138
|
+
|
|
19139
|
+
TODO A short context: what the system does and for whom.
|
|
19140
|
+
|
|
19141
|
+
## Functional requirements
|
|
19142
|
+
|
|
19143
|
+
- **Fetch item**: a user asks for an item by id and gets it back. Name its
|
|
19144
|
+
two scenarios \`"Cache hit"\` and \`"Cache miss"\`.
|
|
19145
|
+
|
|
19146
|
+
Use these use case and scenario names exactly: the traffic, requirements and
|
|
19147
|
+
tests in \`problem.proschi\` refer to them.
|
|
19148
|
+
|
|
19149
|
+
## Scale
|
|
19150
|
+
|
|
19151
|
+
- Fetch item: **1k rps**, 90% of them for items read recently.
|
|
19152
|
+
|
|
19153
|
+
## Constraints
|
|
19154
|
+
|
|
19155
|
+
- p99 of fetching an item under **100 ms**.
|
|
19156
|
+
- Available **99.9%** of the time.
|
|
19157
|
+
- Losing any single machine must not take the service down.
|
|
19158
|
+
- At most **$2,000 / month**.
|
|
19159
|
+
|
|
19160
|
+
## What is given
|
|
19161
|
+
|
|
19162
|
+
\`problem.proschi\` declares the \`user\` and holds the traffic,
|
|
19163
|
+
requirements and tests. Your file imports it; add the components,
|
|
19164
|
+
connections and the use case.
|
|
19165
|
+
`,
|
|
19166
|
+
"given.proschi": `# TODO The problem as Proschi: the fixed nodes the solver connects to, the
|
|
19167
|
+
# traffic, the requirements and the tests that encode the key insight.
|
|
19168
|
+
title "${title}" "TODO One line"
|
|
19169
|
+
|
|
19170
|
+
user "User" [Actor]
|
|
19171
|
+
|
|
19172
|
+
traffic {
|
|
19173
|
+
"Fetch item" 1k rps mix "Cache hit" 90%, "Cache miss" 10%
|
|
19174
|
+
}
|
|
19175
|
+
|
|
19176
|
+
requirements {
|
|
19177
|
+
p99 "Fetch item" < 100ms
|
|
19178
|
+
availability "Fetch item" >= 99.9%
|
|
19179
|
+
survive any node failure
|
|
19180
|
+
cost <= 2000 usd/month
|
|
19181
|
+
}
|
|
19182
|
+
|
|
19183
|
+
test "Items are read from the cache first" {
|
|
19184
|
+
"Fetch item" calls any cache before any database
|
|
19185
|
+
"Fetch item" scenario "Cache hit" never calls any database
|
|
19186
|
+
}
|
|
19187
|
+
`,
|
|
19188
|
+
"starter.proschi": `import "problem.proschi"
|
|
19189
|
+
|
|
19190
|
+
# TODO A small, valid start: usually one use case with its first step.
|
|
19191
|
+
api "Item API" [REST API]
|
|
19192
|
+
|
|
19193
|
+
user -> api
|
|
19194
|
+
|
|
19195
|
+
usecase "Fetch item" {
|
|
19196
|
+
user -> api : GET /items/42
|
|
19197
|
+
api --> user : 200 item
|
|
19198
|
+
}
|
|
19199
|
+
`,
|
|
19200
|
+
"solution.proschi": `import "problem.proschi"
|
|
19201
|
+
|
|
19202
|
+
lb "Load Balancer" [AWS Load Balancer] x2
|
|
19203
|
+
api "Item API" [REST API] x2
|
|
19204
|
+
cache "Item Cache" [Redis] x2
|
|
19205
|
+
db "Items DB" [PostgreSQL] x2
|
|
19206
|
+
|
|
19207
|
+
user -> lb
|
|
19208
|
+
lb -> api : HTTPS
|
|
19209
|
+
api -> cache : GET / SET
|
|
19210
|
+
api -> db : SELECT
|
|
19211
|
+
|
|
19212
|
+
decision "Cache items in Redis" because "Most reads are for recent items; the cache answers them in a millisecond"
|
|
19213
|
+
|
|
19214
|
+
usecase "Fetch item" "Return one item" {
|
|
19215
|
+
user -> lb : GET /items/42
|
|
19216
|
+
lb -> api : GET /items/42
|
|
19217
|
+
api -> cache : GET item:42
|
|
19218
|
+
|
|
19219
|
+
alt "Cache hit" when "the item was read recently" {
|
|
19220
|
+
cache --> api : item
|
|
19221
|
+
} alt "Cache miss" when "the item is not cached" {
|
|
19222
|
+
cache --> api : nil
|
|
19223
|
+
api -> db : SELECT item 42
|
|
19224
|
+
db --> api : item
|
|
19225
|
+
api ->> cache : SET item:42
|
|
19226
|
+
}
|
|
19227
|
+
api --> lb : 200 item
|
|
19228
|
+
lb --> user : 200 item
|
|
19229
|
+
}
|
|
19230
|
+
`,
|
|
19231
|
+
"wrong/database-only.proschi": `# expect-fail: Items are read from the cache first
|
|
19232
|
+
# TODO A plausible wrong design (here: no cache) and the tests it must fail.
|
|
19233
|
+
import "problem.proschi"
|
|
19234
|
+
|
|
19235
|
+
lb "Load Balancer" [AWS Load Balancer] x2
|
|
19236
|
+
api "Item API" [REST API] x2
|
|
19237
|
+
db "Items DB" [PostgreSQL] x2
|
|
19238
|
+
|
|
19239
|
+
user -> lb
|
|
19240
|
+
lb -> api : HTTPS
|
|
19241
|
+
api -> db : SELECT
|
|
19242
|
+
|
|
19243
|
+
usecase "Fetch item" {
|
|
19244
|
+
user -> lb : GET /items/42
|
|
19245
|
+
lb -> api : GET /items/42
|
|
19246
|
+
api -> db : SELECT item 42
|
|
19247
|
+
db --> api : item
|
|
19248
|
+
api --> lb : 200 item
|
|
19249
|
+
lb --> user : 200 item
|
|
19250
|
+
}
|
|
19251
|
+
`
|
|
19252
|
+
};
|
|
19253
|
+
}
|
|
19254
|
+
|
|
19255
|
+
// src/problem.ts
|
|
19256
|
+
var PROBLEM_USAGE = ` proschi problem check [--format text|github|json] [dir]
|
|
19257
|
+
proschi problem new <id> [--dir <problems dir>]`;
|
|
19258
|
+
var PROBLEM_HELP = `problem Practice problems, one folder each (docs/PRACTICE.md).
|
|
19259
|
+
check validates every problem folder in dir (default: the repository's
|
|
19260
|
+
frontend/src/practice/problems when run inside it): front matter, the
|
|
19261
|
+
given (no errors, canonical format), the solution (no diagnostics,
|
|
19262
|
+
canonical, passes every test), the starter (fails a test) and each
|
|
19263
|
+
wrong/ design (fails every test its "# expect-fail:" lines name).
|
|
19264
|
+
Exits with 1 on any violation.
|
|
19265
|
+
new scaffolds a folder that already passes check, with TODOs.`;
|
|
19266
|
+
var REPO_PROBLEMS = (0, import_node_path6.join)("frontend", "src", "practice", "problems");
|
|
19267
|
+
function findProblemsDir(cwd = process.cwd()) {
|
|
19268
|
+
for (let dir = (0, import_node_path6.resolve)(cwd); ; dir = (0, import_node_path6.dirname)(dir)) {
|
|
19269
|
+
const candidate = (0, import_node_path6.join)(dir, REPO_PROBLEMS);
|
|
19270
|
+
if ((0, import_node_fs5.existsSync)(candidate) && (0, import_node_fs5.statSync)(candidate).isDirectory()) return candidate;
|
|
19271
|
+
if ((0, import_node_path6.dirname)(dir) === dir) return void 0;
|
|
19272
|
+
}
|
|
19273
|
+
}
|
|
19274
|
+
function readProblemFolder(folder) {
|
|
19275
|
+
const files = {};
|
|
19276
|
+
for (const entry of (0, import_node_fs5.readdirSync)(folder, { withFileTypes: true })) {
|
|
19277
|
+
if (entry.name.startsWith(".")) continue;
|
|
19278
|
+
if (entry.isFile()) files[entry.name] = (0, import_node_fs5.readFileSync)((0, import_node_path6.join)(folder, entry.name), "utf8");
|
|
19279
|
+
else if (entry.isDirectory()) {
|
|
19280
|
+
for (const sub of (0, import_node_fs5.readdirSync)((0, import_node_path6.join)(folder, entry.name), { withFileTypes: true })) {
|
|
19281
|
+
if (!sub.name.startsWith(".")) files[`${entry.name}/${sub.name}`] = sub.isFile() ? (0, import_node_fs5.readFileSync)((0, import_node_path6.join)(folder, entry.name, sub.name), "utf8") : "";
|
|
19282
|
+
}
|
|
19283
|
+
}
|
|
19284
|
+
}
|
|
19285
|
+
return files;
|
|
19286
|
+
}
|
|
19287
|
+
function checkProblems(dir) {
|
|
19288
|
+
return (0, import_node_fs5.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => e.name).sort().map((id) => {
|
|
19289
|
+
const folder = displayPath3((0, import_node_path6.join)(dir, id));
|
|
19290
|
+
try {
|
|
19291
|
+
const problem = problemFromFiles(id, readProblemFolder((0, import_node_path6.join)(dir, id)));
|
|
19292
|
+
return { folder, loaded: true, ...validateProblem(problem) };
|
|
19293
|
+
} catch (e) {
|
|
19294
|
+
if (!(e instanceof ProblemFolderError)) throw e;
|
|
19295
|
+
const violation = { file: e.file ?? ".", message: e.detail, ...e.line ? { line: e.line } : {} };
|
|
19296
|
+
return { folder, loaded: false, id, tests: 0, starterFails: [], wrong: [], violations: [violation] };
|
|
19297
|
+
}
|
|
19298
|
+
});
|
|
19299
|
+
}
|
|
19300
|
+
var quote2 = (names) => names.map((n) => `"${n}"`).join(", ");
|
|
19301
|
+
var escape3 = (s) => s.replace(/%/g, "%25").replace(/\r?\n/g, "%0A");
|
|
19302
|
+
function formatProblemReports(dir, reports, style) {
|
|
19303
|
+
if (style === "json") return JSON.stringify({ dir: displayPath3(dir), problems: reports }, null, 2);
|
|
19304
|
+
const lines = [];
|
|
19305
|
+
const where = (r, v) => v.file === "." ? r.folder : (0, import_node_path6.join)(r.folder, v.file);
|
|
19306
|
+
for (const r of reports) {
|
|
19307
|
+
if (style === "github") {
|
|
19308
|
+
for (const v of r.violations) lines.push(`::error file=${where(r, v)},line=${v.line ?? 1},title=proschi problem ${r.id}::${escape3(v.message)}`);
|
|
19309
|
+
continue;
|
|
19310
|
+
}
|
|
19311
|
+
const ok = r.violations.length === 0;
|
|
19312
|
+
const wrong = r.wrong.length === 1 ? "1 wrong design" : `${r.wrong.length} wrong designs`;
|
|
19313
|
+
lines.push(`${ok ? "\u2713" : "\u2717"} ${r.id}${r.loaded ? `: ${r.tests} tests, the starter fails ${r.starterFails.length}, ${wrong}` : ""}`);
|
|
19314
|
+
for (const v of r.violations) lines.push(` ${where(r, v)}${v.line ? `:${v.line}` : ""}: ${v.message}`);
|
|
19315
|
+
for (const w of r.wrong) {
|
|
19316
|
+
if (w.missing.length) continue;
|
|
19317
|
+
lines.push(` ${WRONG_DIR}/${w.name}: fails ${quote2(w.expectFail)}${w.alsoFails.length ? `; also fails ${quote2(w.alsoFails)}` : ""}`);
|
|
19318
|
+
}
|
|
19319
|
+
}
|
|
19320
|
+
if (style === "text") {
|
|
19321
|
+
const violations = reports.reduce((n, r) => n + r.violations.length, 0);
|
|
19322
|
+
const wrong = reports.reduce((n, r) => n + r.wrong.length, 0);
|
|
19323
|
+
const bad = reports.filter((r) => r.violations.length).length;
|
|
19324
|
+
lines.push(
|
|
19325
|
+
`${reports.length} problem${reports.length === 1 ? "" : "s"}, ${wrong} wrong design${wrong === 1 ? "" : "s"}: ${violations ? `${violations} violation(s) in ${bad} problem${bad === 1 ? "" : "s"}` : "no violations"}`
|
|
19326
|
+
);
|
|
19327
|
+
}
|
|
19328
|
+
return lines.join("\n");
|
|
19329
|
+
}
|
|
19330
|
+
function problemCheck(args, out, err, cwd) {
|
|
19331
|
+
let style = "text";
|
|
19332
|
+
const paths = [];
|
|
19333
|
+
for (let i = 0; i < args.length; i++) {
|
|
19334
|
+
const arg = args[i];
|
|
19335
|
+
if (arg === "--format") {
|
|
19336
|
+
const value = args[++i];
|
|
19337
|
+
if (value !== "text" && value !== "github" && value !== "json") {
|
|
19338
|
+
err(`Unknown format '${value ?? ""}'; use text, github or json`);
|
|
19339
|
+
return 2;
|
|
19340
|
+
}
|
|
19341
|
+
style = value;
|
|
19342
|
+
} else if (arg.startsWith("-")) {
|
|
19343
|
+
err(`Unknown option ${arg}
|
|
19344
|
+
|
|
19345
|
+
Usage:
|
|
19346
|
+
${PROBLEM_USAGE}`);
|
|
19347
|
+
return 2;
|
|
19348
|
+
} else paths.push(arg);
|
|
19349
|
+
}
|
|
19350
|
+
if (paths.length > 1) {
|
|
19351
|
+
err(`Give one problems directory
|
|
19352
|
+
|
|
19353
|
+
Usage:
|
|
19354
|
+
${PROBLEM_USAGE}`);
|
|
19355
|
+
return 2;
|
|
19356
|
+
}
|
|
19357
|
+
const dir = paths[0] ? (0, import_node_path6.resolve)(cwd, paths[0]) : findProblemsDir(cwd);
|
|
19358
|
+
if (!dir) {
|
|
19359
|
+
err(`Not inside the Proschi repository; give the problems directory
|
|
19360
|
+
|
|
19361
|
+
Usage:
|
|
19362
|
+
${PROBLEM_USAGE}`);
|
|
19363
|
+
return 2;
|
|
19364
|
+
}
|
|
19365
|
+
if (!(0, import_node_fs5.existsSync)(dir) || !(0, import_node_fs5.statSync)(dir).isDirectory()) {
|
|
19366
|
+
err(`No such directory: ${paths[0] ?? dir}`);
|
|
19367
|
+
return 2;
|
|
19368
|
+
}
|
|
19369
|
+
const reports = checkProblems(dir);
|
|
19370
|
+
if (reports.length === 0) {
|
|
19371
|
+
err(`No problem folders in ${displayPath3(dir)}`);
|
|
19372
|
+
return 1;
|
|
19373
|
+
}
|
|
19374
|
+
const text = formatProblemReports(dir, reports, style);
|
|
19375
|
+
if (text) out(text);
|
|
19376
|
+
return reports.some((r) => r.violations.length > 0) ? 1 : 0;
|
|
19377
|
+
}
|
|
19378
|
+
function problemNew(args, out, err, cwd) {
|
|
19379
|
+
let dirArg;
|
|
19380
|
+
const ids = [];
|
|
19381
|
+
for (let i = 0; i < args.length; i++) {
|
|
19382
|
+
const arg = args[i];
|
|
19383
|
+
if (arg === "--dir") {
|
|
19384
|
+
dirArg = args[++i];
|
|
19385
|
+
if (!dirArg) {
|
|
19386
|
+
err("Expected a directory after --dir");
|
|
19387
|
+
return 2;
|
|
19388
|
+
}
|
|
19389
|
+
} else if (arg.startsWith("-")) {
|
|
19390
|
+
err(`Unknown option ${arg}
|
|
19391
|
+
|
|
19392
|
+
Usage:
|
|
19393
|
+
${PROBLEM_USAGE}`);
|
|
19394
|
+
return 2;
|
|
19395
|
+
} else ids.push(arg);
|
|
19396
|
+
}
|
|
19397
|
+
if (ids.length !== 1) {
|
|
19398
|
+
err(`Usage:
|
|
19399
|
+
${PROBLEM_USAGE}`);
|
|
19400
|
+
return 2;
|
|
19401
|
+
}
|
|
19402
|
+
const id = ids[0];
|
|
19403
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
|
|
19404
|
+
err(`The id '${id}' must be lowercase words joined by "-", e.g. url-shortener (it is the folder name and the URL)`);
|
|
19405
|
+
return 2;
|
|
19406
|
+
}
|
|
19407
|
+
const dir = dirArg ? (0, import_node_path6.resolve)(cwd, dirArg) : findProblemsDir(cwd) ?? (0, import_node_path6.resolve)(cwd, "problems");
|
|
19408
|
+
const folder = (0, import_node_path6.join)(dir, id);
|
|
19409
|
+
if ((0, import_node_fs5.existsSync)(folder)) {
|
|
19410
|
+
err(`${displayPath3(folder)} already exists`);
|
|
19411
|
+
return 1;
|
|
19412
|
+
}
|
|
19413
|
+
const files = problemTemplate(id);
|
|
19414
|
+
for (const [name, text] of Object.entries(files)) {
|
|
19415
|
+
(0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)((0, import_node_path6.join)(folder, name)), { recursive: true });
|
|
19416
|
+
(0, import_node_fs5.writeFileSync)((0, import_node_path6.join)(folder, name), text);
|
|
19417
|
+
}
|
|
19418
|
+
const shown = displayPath3(folder);
|
|
19419
|
+
out(`Created ${shown}/
|
|
19420
|
+
${Object.keys(files).join("\n ")}
|
|
19421
|
+
|
|
19422
|
+
It already passes \`proschi problem check\`; now make it your problem:
|
|
19423
|
+
1. problem.md: title, summary, difficulty, tags, hints, and the statement
|
|
19424
|
+
(name every use case in bold and spell out the scenario names).
|
|
19425
|
+
2. given.proschi: the fixed nodes, the traffic, the requirements and the
|
|
19426
|
+
tests that encode the key insight. Capacity overrides go here only.
|
|
19427
|
+
3. solution.proschi: a good answer that passes every test; starter.proschi:
|
|
19428
|
+
a small valid start that fails at least one.
|
|
19429
|
+
4. Calibrate: set the latency, cost and availability limits between the
|
|
19430
|
+
reference design and brute-force designs, and prove it with wrong/
|
|
19431
|
+
designs that start with "# expect-fail: <test name>".
|
|
19432
|
+
5. Run: proschi problem check ${displayPath3(dir)}`);
|
|
19433
|
+
return 0;
|
|
19434
|
+
}
|
|
19435
|
+
function runProblem(args, out, err, cwd = process.cwd()) {
|
|
19436
|
+
const [sub, ...rest] = args;
|
|
19437
|
+
if (sub === "check") return problemCheck(rest, out, err, cwd);
|
|
19438
|
+
if (sub === "new") return problemNew(rest, out, err, cwd);
|
|
19439
|
+
err(`${sub ? `Unknown problem command '${sub}'
|
|
19440
|
+
|
|
19441
|
+
` : ""}Usage:
|
|
19442
|
+
${PROBLEM_USAGE}`);
|
|
19443
|
+
return 2;
|
|
19444
|
+
}
|
|
19445
|
+
|
|
18187
19446
|
// src/cli.ts
|
|
18188
|
-
var VERSION = true ? "0.
|
|
19447
|
+
var VERSION = true ? "0.5.0" : "dev";
|
|
18189
19448
|
var USAGE = `Usage:
|
|
18190
19449
|
proschi check [--strict] [--format text|github|json] [--openapi <node>=<spec>]... <file|dir>...
|
|
18191
19450
|
proschi parse <file>
|
|
18192
19451
|
${RENDER_USAGE}
|
|
18193
19452
|
${FMT_USAGE}
|
|
18194
19453
|
${SIM_USAGE}
|
|
19454
|
+
${PROBLEM_USAGE}
|
|
18195
19455
|
proschi --version
|
|
18196
19456
|
|
|
18197
19457
|
check Reports errors and warnings. Directories are searched for *.proschi files.
|
|
@@ -18204,19 +19464,20 @@ parse Prints {"diagram", "diagnostics"} as JSON; the shape is described by
|
|
|
18204
19464
|
schema/proschi-diagram.schema.json.
|
|
18205
19465
|
${RENDER_HELP}
|
|
18206
19466
|
${FMT_HELP}
|
|
18207
|
-
${SIM_HELP}
|
|
19467
|
+
${SIM_HELP}
|
|
19468
|
+
${PROBLEM_HELP}`;
|
|
18208
19469
|
function collectFiles(paths) {
|
|
18209
19470
|
const out = [];
|
|
18210
19471
|
const walk = (dir) => {
|
|
18211
|
-
for (const entry of (0,
|
|
19472
|
+
for (const entry of (0, import_node_fs6.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
18212
19473
|
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
18213
|
-
const full = (0,
|
|
19474
|
+
const full = (0, import_node_path7.join)(dir, entry.name);
|
|
18214
19475
|
if (entry.isDirectory()) walk(full);
|
|
18215
19476
|
else if (entry.name.endsWith(".proschi")) out.push(full);
|
|
18216
19477
|
}
|
|
18217
19478
|
};
|
|
18218
19479
|
for (const p of paths) {
|
|
18219
|
-
if ((0,
|
|
19480
|
+
if ((0, import_node_fs6.statSync)(p).isDirectory()) walk(p);
|
|
18220
19481
|
else out.push(p);
|
|
18221
19482
|
}
|
|
18222
19483
|
return out;
|
|
@@ -18264,6 +19525,7 @@ function run(argv, out = console.log, err = console.error) {
|
|
|
18264
19525
|
if (command === "fmt") return runFmt(rest, collectFiles, out, err);
|
|
18265
19526
|
if (command === "test") return runTest(rest, collectFiles, out, err);
|
|
18266
19527
|
if (command === "analyze") return runAnalyze(rest, out, err);
|
|
19528
|
+
if (command === "problem") return runProblem(rest, out, err);
|
|
18267
19529
|
if (command === "check") {
|
|
18268
19530
|
let strict = false;
|
|
18269
19531
|
let style = "text";
|