proschi 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -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 = [];
@@ -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
  );
@@ -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,19 +18492,18 @@ 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
  }
@@ -18002,8 +18561,9 @@ var SIM_USAGE = ` proschi test [--format text|github|json] <file|dir>...
18002
18561
  var SIM_HELP = `test Runs the requirements and test blocks of each file (imports are
18003
18562
  followed) against the simulation. Exits with 1 if any fails or a file
18004
18563
  has errors.
18005
- analyze Prints the capacity table: load, utilisation, latency, availability
18006
- and cost per node; latency percentiles per use case and scenario.`;
18564
+ analyze Prints the capacity table: reads and writes (load of capacity and
18565
+ utilisation), latency, availability, egress and cost per node; latency
18566
+ percentiles per use case and scenario.`;
18007
18567
  function testFiles(files, load = parseFile) {
18008
18568
  return files.map((file) => {
18009
18569
  const { diagram, diagnostics } = load(file);
@@ -18111,27 +18671,38 @@ function table(rows, left = 1) {
18111
18671
  const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => r[c].length)));
18112
18672
  return rows.map((r) => r.map((cell, c) => c < left ? cell.padEnd(widths[c]) : cell.padStart(widths[c])).join(" ").trimEnd());
18113
18673
  }
18674
+ function accessCell(load, capacity, utilization) {
18675
+ return load > 0 ? `${formatRps(load).replace(" rps", "")}/${formatRps(capacity)} ${formatPercent(utilization)}` : "-";
18676
+ }
18677
+ function formatGb(gb) {
18678
+ const n = (x, digits) => Number(x.toFixed(digits)).toLocaleString("en-US");
18679
+ return gb >= 1e3 ? `${n(gb / 1e3, 1)} TB` : `${n(gb, gb < 10 ? 1 : 0)} GB`;
18680
+ }
18681
+ function egressCell(n) {
18682
+ return n.egressGbPerMonth > 0 ? `${formatUsd(n.egressUsd).replace("/month", "")} (${formatGb(n.egressGbPerMonth)})` : "-";
18683
+ }
18114
18684
  function capacityReport(diagram, analysis, file) {
18115
18685
  const lines = [diagram.title ? `${diagram.title} (${file})` : file, ""];
18116
18686
  if ((diagram.traffic ?? []).length === 0) lines.push("No traffic { \u2026 } block: loads are zero and latencies are at idle.", "");
18117
18687
  const nodes = analysis.nodes.filter((n) => n.kind !== "client" && n.kind !== "other");
18118
18688
  lines.push(
18119
18689
  ...table([
18120
- ["Node", "Kind", "Replicas", "Load", "Capacity", "Util", "Latency", "Availability", "Cost/month"],
18690
+ ["Node", "Kind", "Replicas", "Reads", "Writes", "Util", "Latency", "Availability", "Egress/month", "Cost/month"],
18121
18691
  ...nodes.map((n) => [
18122
18692
  n.id,
18123
18693
  n.kind,
18124
- String(n.replicas),
18125
- formatRps(n.loadRps),
18126
- formatRps(n.capacityRps),
18694
+ n.shards > 1 ? `${n.replicas}x${n.shards} shards` : String(n.replicas),
18695
+ accessCell(n.readLoadRps, n.readCapacityRps, n.readUtilization),
18696
+ accessCell(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization),
18127
18697
  `${formatPercent(n.utilization)}${n.saturated ? " SATURATED" : n.utilization > HOT ? " hot" : ""}`,
18128
18698
  formatMs(n.latencyMs),
18129
18699
  formatAvailability(n.availability),
18700
+ egressCell(n),
18130
18701
  formatUsd(n.costUsd).replace("/month", "")
18131
18702
  ])
18132
18703
  ], 2),
18133
18704
  "",
18134
- `Total cost: ${formatUsd(analysis.totalCostUsd)}`,
18705
+ `Total cost: ${formatUsd(analysis.totalCostUsd)}${analysis.totalEgressUsd > 0 ? ` (${formatUsd(analysis.totalEgressUsd)} of it egress)` : ""}`,
18135
18706
  ""
18136
18707
  );
18137
18708
  if (analysis.useCases.length) {
@@ -18185,7 +18756,7 @@ function checkFiles(files, extra) {
18185
18756
  }
18186
18757
 
18187
18758
  // src/cli.ts
18188
- var VERSION = true ? "0.3.0" : "dev";
18759
+ var VERSION = true ? "0.4.0" : "dev";
18189
18760
  var USAGE = `Usage:
18190
18761
  proschi check [--strict] [--format text|github|json] [--openapi <node>=<spec>]... <file|dir>...
18191
18762
  proschi parse <file>