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/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 = resolve3.call(this, root, ref);
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 resolve3(root, ref) {
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 resolve3(baseURI, relativeURI, options) {
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: resolve3,
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, scalar, onError) {
11873
- const start = scalar.offset;
11874
- const header = parseBlockScalarHeader(scalar, ctx.options.strict, onError);
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 = scalar.source ? splitLines(scalar.source) : [];
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 (scalar.source)
11891
- end2 += scalar.source.length;
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 = scalar.indent + header.indent;
11895
- let offset = scalar.offset + header.length;
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 + scalar.source.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(scalar, strict, onError) {
12057
- const { offset, type, source: source2, end } = scalar;
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(scalar, "UNEXPECTED_TOKEN", `Expected a flow scalar value, but found: ${type}`);
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 scalar;
12291
+ let scalar2;
12292
12292
  try {
12293
12293
  const res = tag.resolve(value, (msg) => onError(tagToken ?? token, "TAG_RESOLVE_FAILED", msg), ctx.options);
12294
- scalar = identity.isScalar(res) ? res : new Scalar.Scalar(res);
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
- scalar = new Scalar.Scalar(value);
12298
+ scalar2 = new Scalar.Scalar(value);
12299
12299
  }
12300
- scalar.range = range;
12301
- scalar.source = value;
12300
+ scalar2.range = range;
12301
+ scalar2.source = value;
12302
12302
  if (type)
12303
- scalar.type = type;
12303
+ scalar2.type = type;
12304
12304
  if (tagName)
12305
- scalar.tag = tagName;
12305
+ scalar2.tag = tagName;
12306
12306
  if (tag.format)
12307
- scalar.format = tag.format;
12307
+ scalar2.format = tag.format;
12308
12308
  if (comment)
12309
- scalar.comment = comment;
12310
- return scalar;
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 quote2 = this.charAt(0);
13468
- let end = this.buffer.indexOf(quote2, this.pos + 1);
13469
- if (quote2 === "'") {
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(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 (scalar.end) {
14144
- sep = scalar.end;
14143
+ if (scalar2.end) {
14144
+ sep = scalar2.end;
14145
14145
  sep.push(this.sourceToken);
14146
- delete scalar.end;
14146
+ delete scalar2.end;
14147
14147
  } else
14148
14148
  sep = [this.sourceToken];
14149
14149
  const map = {
14150
14150
  type: "block-map",
14151
- offset: scalar.offset,
14152
- indent: scalar.indent,
14153
- items: [{ start, key: scalar, sep }]
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(scalar);
14158
+ yield* this.lineEnd(scalar2);
14159
14159
  }
14160
- *blockScalar(scalar) {
14160
+ *blockScalar(scalar2) {
14161
14161
  switch (this.type) {
14162
14162
  case "space":
14163
14163
  case "comment":
14164
14164
  case "newline":
14165
- scalar.props.push(this.sourceToken);
14165
+ scalar2.props.push(this.sourceToken);
14166
14166
  return;
14167
14167
  case "scalar":
14168
- scalar.source = this.source;
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 import_node_fs5 = require("node:fs");
14801
- var import_node_path6 = require("node:path");
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 quote2 = null;
15467
+ let quote3 = null;
15468
15468
  for (let i = 0; i < text.length; i++) {
15469
15469
  const ch = text[i];
15470
- if (quote2) {
15470
+ if (quote3) {
15471
15471
  if (ch === "\\") i++;
15472
- else if (ch === quote2) quote2 = null;
15472
+ else if (ch === quote3) quote3 = null;
15473
15473
  continue;
15474
15474
  }
15475
- if (ch === '"') quote2 = 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 API Gateway": "edge",
15536
- "Azure API Management": "edge",
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 after = UNITS.get(rest);
15580
- if (magnitude && (rest === "" || after)) {
15581
- return after ? { value: round(number * magnitude * after.factor), unit: after.unit } : { value: round(number * magnitude) };
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, % or usd/month (with k, m or b for thousands, millions, billions)` };
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 ASSERTION_HELP = 'Expected an assertion: "Use case" calls <node>, "Use case" writes <node> before responding, no path from <node> to <node>, <node> has replicas >= 2, \u2026';
15616
- var ASSERTION_VERBS = "calls, every scenario calls, never calls, writes, responds, has scenario or handles failure of";
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: resolve3 } = this.options;
15805
- if (!resolve3) {
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 = resolve3(path, this.file ?? this.options.path);
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
- body.items.push({ kind: "step", step: { from, to, arrow, label, parallelGroup: top.kind === "par" ? top.group : void 0, loc } });
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) this.deferred.push(() => this.checkUseCase(named.name, named.loc));
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 a rate, latency, availability, cost, durable or volatile`, this.locOf(t, line));
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 a rate, latency, availability, cost, durable or volatile`, idLoc);
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
- if (!this.nodes.has(id.value)) this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
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 target = selector(0);
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
- if (!isWord(tokens[target.next], "has")) {
16489
- this.error(ASSERTION_HELP, this.locOf(tokens[target.next] ?? first, line));
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]` or `any <kind>` starting at `tokens[i]`. */
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({ selector, token, loc }) {
16600
- if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
16601
- if ("node" in selector) {
16602
- this.deferred.push(() => {
16603
- if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
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, after, line) {
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 ?? after, line));
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 after = raw.slice(last.col);
17097
- return after.slice(stripTrailingComment(after).length).trim();
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) => s.outcome === "success" && s.steps.some((step) => step.toServiceId === nodeId && step.failed) && !callsOk(s, nodeId)
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
- return kindOf(node) === selector.kind;
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
- function computeLoad(diagram, traffic) {
17471
- const load = /* @__PURE__ */ new Map();
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) load.set(step.toServiceId, (load.get(step.toServiceId) ?? 0) + rate);
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 byId = new Map(nodes.map((n) => [n.id, n]));
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 loadRps = load.get(n.id) ?? 0;
17516
- const capacityRps = p.rps * replicas;
17517
- const utilization = utilizationOf(loadRps, capacityRps);
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
- loadRps,
17523
- capacityRps,
17524
- utilization,
17525
- saturated: utilization >= 1,
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 = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
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
- id: `req:${i + 1}`,
17601
- name: requirementName(r),
17602
- category: CATEGORY[r.kind],
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 = new Set(u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []));
17674
- const saturated = [...touched].map((id) => run2.nodes.get(id)).filter((n) => !!n?.saturated);
17675
- if (saturated.length) {
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
- return ms < maxMs ? { ...pass(message), value } : { ...fail(message, latencyHint(run2, u, result, key, ms)), value };
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 perReplica = n.capacityRps / n.replicas;
17688
- const replicas = Math.ceil(n.loadRps / (perReplica * HOT));
17689
- return `Add replicas to ${nodeLabel(run2.diagram, n.id)}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
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, result, key, ms) {
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 `${lead}Shorten the synchronous path of the request.`;
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 `${lead}It waits ${formatMs(DEFAULT_TIMEOUT_MS)} for the failed call to ${label}; give that path a smaller share of traffic or avoid the call.`;
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 `${lead}${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
17703
- return `${lead}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 ->>.`;
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 writes = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
17719
- if (writes.some(({ m, i }) => m.async && i < beforeResponse)) return "async";
17720
- if (writes.some(({ i }) => i >= beforeResponse)) return "after";
17721
- return "none";
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
- var GAP_TEXT = {
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.map((s) => ({ s, ok: writesBeforeResponse(u, s, accept) })).filter((x) => !x.ok);
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(({ s }) => ({ s, gap: writeGap(u, s, accept) }));
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 message = details.map(({ s, gap: gap2 }) => `${where(s)} ${GAP_TEXT[gap2]} ${what} before responding`).join("; ");
17739
- const gap = details[0].gap;
17740
- const hint = gap === "async" ? `Make the write a synchronous request (->) so it is acknowledged before the response` : gap === "after" ? `Move the write before the step that answers the entry request` : `Add a synchronous write (->) to ${what} before the entry request is answered`;
17741
- return fail(message, hint);
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, "to a durable store");
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 = n.capacityRps / replicas * (replicas - 1);
17762
- const rho = n.loadRps / left;
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(rho)})`,
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.totalCostUsd;
17793
- const message = `Total cost is ${formatUsd(total)} (limit ${formatUsd(max)})`;
18231
+ const { totalCostUsd: total, totalEgressUsd: egress } = run2.analysis;
18232
+ const ofWhich = egress > 0 ? `, ${formatUsd(egress)} of it egress` : "";
18233
+ const message = `Total cost is ${formatUsd(total)}${ofWhich} (limit ${formatUsd(max)})`;
17794
18234
  if (total <= max) return pass(message);
17795
- const top = [...run2.analysis.nodes].filter((n) => n.costUsd > 0).sort((a, b) => b.costUsd - a.costUsd).slice(0, 3).map((n) => `${n.id} ${formatUsd(n.costUsd)}${n.replicas > 1 ? ` (${n.replicas} replicas)` : ""}`);
17796
- return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.`);
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 assertions = test.assertions.map((a) => ({ ...assertion(run2, a), loc: a.loc }));
17800
- const failed = assertions.filter((a) => !a.passed);
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
- return { passed: false, message: failed.map((a) => a.message).join("; "), hint: failed[0].hint, assertions };
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 fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`);
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 = new Set(selectNodes(run2.diagram, a.target).map((n) => n.id));
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 = new Set(selectNodes(run2.diagram, a.target).map((n) => n.id));
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 = new Set(selectNodes(run2.diagram, a.target).map((n) => n.id));
17874
- const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId)).map((step) => 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, called(first)[0])}`,
17883
- `Take the call to ${called(first)[0]} out of ${quote(first.name)}, e.g. serve it from a cache`
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 = new Set(selectNodes(run2.diagram, a.first).map((n) => n.id));
17899
- const then = new Set(selectNodes(run2.diagram, a.then).map((n) => n.id));
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.map((m) => m.to);
17906
- const iy = order.findIndex((id) => then.has(id));
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((id) => first.has(id));
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 = new Set(selectNodes(run2.diagram, from).map((n) => n.id));
17936
- const targets = new Set(selectNodes(run2.diagram, to).map((n) => n.id));
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} (${line(edge.loc)}) goes directly from ${x} to ${y}`, hint);
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} (${line(step.loc)}) goes directly from ${x} to ${y}`, hint);
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, utilisation, latency, availability
18006
- and cost per node; latency percentiles per use case and scenario.`;
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", "Load", "Capacity", "Util", "Latency", "Availability", "Cost/month"],
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
- formatRps(n.loadRps),
18126
- formatRps(n.capacityRps),
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.3.0" : "dev";
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, import_node_fs5.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
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, import_node_path6.join)(dir, entry.name);
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, import_node_fs5.statSync)(p).isDirectory()) walk(p);
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";