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/server.cjs CHANGED
@@ -25435,7 +25435,7 @@ var IDENT_PART = /[A-Za-z0-9_]/;
25435
25435
  var TEAM_PART = /[A-Za-z0-9_-]/;
25436
25436
  var DIGIT = /[0-9]/;
25437
25437
  var LETTER = /[A-Za-z]/;
25438
- var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month)(?![A-Za-z0-9_/])/;
25438
+ var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month|usd\/GB|MB\/s|GB\/s)(?![A-Za-z0-9_/])/;
25439
25439
  function tokenizeLine(text, line) {
25440
25440
  const tokens = [];
25441
25441
  const diagnostics = [];
@@ -25627,11 +25627,21 @@ function endpointGroupKey(endpoint, all) {
25627
25627
  }
25628
25628
 
25629
25629
  // ../frontend/src/dsl/kinds.ts
25630
- var KINDS = ["client", "edge", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
25630
+ var KINDS = ["client", "edge", "cdn", "loadbalancer", "gateway", "dns", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
25631
25631
  var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
25632
+ var EDGE_KINDS = ["edge", "cdn", "loadbalancer", "gateway", "dns"];
25632
25633
  var BY_TECH = {
25633
- "AWS API Gateway": "edge",
25634
- "Azure API Management": "edge",
25634
+ "AWS CloudFront": "cdn",
25635
+ "Azure CDN": "cdn",
25636
+ "GCP Cloud CDN": "cdn",
25637
+ "Azure Front Door": "cdn",
25638
+ "AWS Load Balancer": "loadbalancer",
25639
+ "GCP Load Balancing": "loadbalancer",
25640
+ "AWS API Gateway": "gateway",
25641
+ "Azure API Management": "gateway",
25642
+ "AWS Route53": "dns",
25643
+ "Azure DNS": "dns",
25644
+ "GCP Cloud DNS": "dns",
25635
25645
  Redis: "cache",
25636
25646
  Elasticsearch: "search",
25637
25647
  "GCP BigQuery": "analytics",
@@ -25663,6 +25673,12 @@ function isKind(word) {
25663
25673
  function isDataStore(kind) {
25664
25674
  return DATA_STORE_KINDS.includes(kind);
25665
25675
  }
25676
+ function isEdge(kind) {
25677
+ return EDGE_KINDS.includes(kind);
25678
+ }
25679
+ function kindMatches(actual, wanted) {
25680
+ return actual === wanted || wanted === "edge" && isEdge(actual);
25681
+ }
25666
25682
 
25667
25683
  // ../frontend/src/dsl/quantity.ts
25668
25684
  function parseQuantity(text) {
@@ -25674,11 +25690,11 @@ function parseQuantity(text) {
25674
25690
  if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
25675
25691
  const magnitude = MAGNITUDES.get(suffix[0]);
25676
25692
  const rest = suffix.slice(1);
25677
- const after = UNITS.get(rest);
25678
- if (magnitude && (rest === "" || after)) {
25679
- return after ? { value: round(number * magnitude * after.factor), unit: after.unit } : { value: round(number * magnitude) };
25693
+ const after2 = UNITS.get(rest);
25694
+ if (magnitude && (rest === "" || after2)) {
25695
+ return after2 ? { value: round(number * magnitude * after2.factor), unit: after2.unit } : { value: round(number * magnitude) };
25680
25696
  }
25681
- 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)` };
25697
+ return { error: `Unknown unit '${suffix}' in '${text}'; use rps, rpm, rpd, ms, s, %, usd/month, MB/s, GB/s or usd/GB (with k, m or b for thousands, millions, billions)` };
25682
25698
  }
25683
25699
  var round = (n) => Number(n.toPrecision(12));
25684
25700
  var MAGNITUDES = /* @__PURE__ */ new Map([
@@ -25693,9 +25709,67 @@ var UNITS = /* @__PURE__ */ new Map([
25693
25709
  ["ms", { unit: "ms", factor: 1 }],
25694
25710
  ["s", { unit: "ms", factor: 1e3 }],
25695
25711
  ["%", { unit: "%", factor: 1 }],
25696
- ["usd/month", { unit: "usd/month", factor: 1 }]
25712
+ ["usd/month", { unit: "usd/month", factor: 1 }],
25713
+ ["MB/s", { unit: "MBps", factor: 1 }],
25714
+ ["GB/s", { unit: "MBps", factor: 1e3 }],
25715
+ ["usd/GB", { unit: "usd/GB", factor: 1 }]
25697
25716
  ]);
25698
25717
 
25718
+ // ../frontend/src/dsl/access.ts
25719
+ var WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
25720
+ var WRITE_VERBS = [
25721
+ "INSERT",
25722
+ "UPDATE",
25723
+ "UPSERT",
25724
+ "DELETE",
25725
+ "PUT",
25726
+ "SET",
25727
+ "WRITE",
25728
+ "APPEND",
25729
+ "INCR",
25730
+ "DECR",
25731
+ "LPUSH",
25732
+ "RPUSH",
25733
+ "ZADD",
25734
+ "HSET",
25735
+ "GEOADD",
25736
+ "PUBLISH",
25737
+ "SEND",
25738
+ "ENQUEUE",
25739
+ "PRODUCE",
25740
+ "CHARGE",
25741
+ "CREATE",
25742
+ // DynamoDB and Redis commands that store data
25743
+ "PUTITEM",
25744
+ "UPDATEITEM",
25745
+ "DELETEITEM",
25746
+ "BATCHWRITEITEM",
25747
+ "INCRBY",
25748
+ "HINCRBY",
25749
+ "SADD",
25750
+ "XADD",
25751
+ "MSET",
25752
+ // Plain words for storing something
25753
+ "SAVE",
25754
+ "STORE",
25755
+ "UPLOAD",
25756
+ "COMMIT",
25757
+ "RECORD",
25758
+ "MARK",
25759
+ // Claiming a resource changes state: a seat, a driver, stock
25760
+ "RESERVE",
25761
+ "HOLD",
25762
+ "BOOK",
25763
+ // Handing something on: a write at the receiver (a queue, a provider), like SEND and PUBLISH
25764
+ "EMIT",
25765
+ "NOTIFY"
25766
+ ];
25767
+ function accessOf(httpMethod, label) {
25768
+ if (httpMethod) return WRITE_METHODS.includes(httpMethod.toUpperCase()) ? "write" : "read";
25769
+ const word = /^\s*"?([A-Za-z_]+)/.exec(label)?.[1];
25770
+ return word && WRITE_VERBS.includes(word.toUpperCase()) ? "write" : "read";
25771
+ }
25772
+
25699
25773
  // ../frontend/src/dsl/parser.ts
25700
25774
  var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
25701
25775
  var DEFAULT_TECH = "Rectangle";
@@ -25708,10 +25782,13 @@ var UNIT_EXAMPLES = {
25708
25782
  rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
25709
25783
  ms: "a duration, e.g. 50ms or 1.5s",
25710
25784
  "%": "a percentage, e.g. 99.9%",
25711
- "usd/month": "a monthly cost, e.g. 400 usd/month"
25785
+ "usd/month": "a monthly cost, e.g. 400 usd/month",
25786
+ MBps: "a bandwidth, e.g. 500 MB/s or 1 GB/s",
25787
+ "usd/GB": "a price per gigabyte, e.g. 0.05 usd/GB"
25712
25788
  };
25713
- 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';
25714
- var ASSERTION_VERBS = "calls, every scenario calls, never calls, writes, responds, has scenario or handles failure of";
25789
+ var CAPACITY_PARTS = "a rate, reads, writes, shards, latency, availability, cost, durable or volatile, consistency, bandwidth or egress";
25790
+ var ASSERTION_HELP = 'Expected an assertion: "Use case" calls <node>, "Use case" writes <node> before responding, <node> calls <node>, no path from <node> to <node>, <node> has replicas >= 2, \u2026';
25791
+ var ASSERTION_VERBS = "calls, every scenario calls, never calls, never waits for, writes, responds, starts at, has scenario or handles failure of";
25715
25792
  var techByName = new Map(
25716
25793
  componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
25717
25794
  );
@@ -26142,7 +26219,20 @@ var Parser = class {
26142
26219
  if (top?.kind === "usecase" || top?.kind === "alt" || top?.kind === "par") {
26143
26220
  const body = this.currentBody();
26144
26221
  body.openAlt = void 0;
26145
- body.items.push({ kind: "step", step: { from, to, arrow, label, parallelGroup: top.kind === "par" ? top.group : void 0, loc } });
26222
+ const prefixes = arrow === "-->" ? { rest: label } : this.readLabelPrefixes(label, labelToken, line);
26223
+ body.items.push({
26224
+ kind: "step",
26225
+ step: {
26226
+ from,
26227
+ to,
26228
+ arrow,
26229
+ label: prefixes.rest,
26230
+ parallelGroup: top.kind === "par" ? top.group : void 0,
26231
+ ...prefixes.multiplier !== void 0 ? { multiplier: prefixes.multiplier } : {},
26232
+ ...prefixes.sizeBytes !== void 0 ? { sizeBytes: prefixes.sizeBytes } : {},
26233
+ loc
26234
+ }
26235
+ });
26146
26236
  } else if (arrow === "-x") {
26147
26237
  this.error("'-x' marks a failed call and is only allowed in use case steps", this.locOf(arrowToken, line));
26148
26238
  } else {
@@ -26152,6 +26242,42 @@ var Parser = class {
26152
26242
  }
26153
26243
  return lastLine;
26154
26244
  }
26245
+ /**
26246
+ * Reads `x<N>` (fan-out) and `~<size>` (payload size) at the start of a step
26247
+ * label, in either order (§7.3), and returns the label without them. A
26248
+ * prefix must be followed by whitespace or end the label, so `xml …` and
26249
+ * `x-request-id` are ordinary labels. Problems are reported at the prefix.
26250
+ */
26251
+ readLabelPrefixes(label, token, line) {
26252
+ const out = { rest: label };
26253
+ if (!token) return out;
26254
+ const after2 = this.lines[line - 1].slice(token.col);
26255
+ const base = token.col + 1 + (after2.length - after2.trimStart().length);
26256
+ let offset = 0;
26257
+ for (; ; ) {
26258
+ const rest = label.slice(offset);
26259
+ const at = (length) => ({ line, col: base + offset, length, ...this.fileField() });
26260
+ const fanOut = /^x(\d+)(?=\s|$)/.exec(rest);
26261
+ const size = rest.startsWith("~") ? /^~(\S*)/.exec(rest) : null;
26262
+ const match = fanOut ?? size;
26263
+ if (!match) break;
26264
+ if (fanOut) {
26265
+ const n = Number(fanOut[1]);
26266
+ if (out.multiplier !== void 0) this.error("The fan-out is given twice; write one x<N>", at(match[0].length));
26267
+ else if (n < 1) this.error("A fan-out needs at least one call per request: x1, x2, \u2026", at(match[0].length));
26268
+ else out.multiplier = n;
26269
+ } else {
26270
+ const bytes = parseSize(size[1]);
26271
+ if (out.sizeBytes !== void 0) this.error("The payload size is given twice; write one ~<size>", at(match[0].length));
26272
+ else if ("error" in bytes) this.error(bytes.error, at(match[0].length));
26273
+ else out.sizeBytes = bytes.value;
26274
+ }
26275
+ offset += match[0].length;
26276
+ offset += label.slice(offset).length - label.slice(offset).trimStart().length;
26277
+ }
26278
+ out.rest = label.slice(offset);
26279
+ return out;
26280
+ }
26155
26281
  // ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
26156
26282
  /**
26157
26283
  * Starts a section block. Sections are top level only; a misplaced one, or
@@ -26275,10 +26401,14 @@ var Parser = class {
26275
26401
  useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
26276
26402
  i++;
26277
26403
  };
26278
- const commit = (requirement2) => {
26404
+ const commit = (requirement2, scenario) => {
26279
26405
  this.requirements.push(requirement2);
26280
26406
  const named = useCase;
26281
- if (named) this.deferred.push(() => this.checkUseCase(named.name, named.loc));
26407
+ if (named)
26408
+ this.deferred.push(() => {
26409
+ const found = this.checkUseCase(named.name, named.loc);
26410
+ if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
26411
+ });
26282
26412
  };
26283
26413
  const withUseCase = () => useCase ? { useCase: useCase.name } : {};
26284
26414
  if (/^p\d+$/.test(word)) {
@@ -26288,10 +26418,24 @@ var Parser = class {
26288
26418
  return;
26289
26419
  }
26290
26420
  readUseCase();
26421
+ let scenario;
26422
+ if (isWord(tokens[i], "scenario")) {
26423
+ const name = tokens[i + 1];
26424
+ if (!useCase) {
26425
+ this.error(`A scenario belongs to a use case; write ${word} "Use case" scenario "Scenario" < 100ms`, this.locOf(tokens[i], line));
26426
+ return;
26427
+ }
26428
+ if (name?.kind !== "string") {
26429
+ this.error("Expected a scenario name in quotes after scenario", this.locOf(name ?? tokens[i], line));
26430
+ return;
26431
+ }
26432
+ scenario = { name: name.value, loc: this.locOf(name, line) };
26433
+ i += 2;
26434
+ }
26291
26435
  if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
26292
26436
  const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
26293
26437
  if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
26294
- commit({ kind: "latency", percentile, ...withUseCase(), maxMs, loc });
26438
+ commit({ kind: "latency", percentile, ...withUseCase(), ...scenario ? { scenario: scenario.name } : {}, maxMs, loc }, scenario);
26295
26439
  return;
26296
26440
  }
26297
26441
  switch (word) {
@@ -26374,8 +26518,50 @@ var Parser = class {
26374
26518
  part = "durable or volatile";
26375
26519
  override.durable = t.value === "durable";
26376
26520
  i++;
26521
+ } else if (isWord(t, "reads") || isWord(t, "writes")) {
26522
+ part = t.value;
26523
+ const value = this.quantityOf(tokens[i + 1], "rps", t, line);
26524
+ if (value === void 0) return;
26525
+ if (part === "reads") override.readRps = value;
26526
+ else override.writeRps = value;
26527
+ i += 2;
26528
+ } else if (isWord(t, "shards")) {
26529
+ part = "shards";
26530
+ const n = tokens[i + 1];
26531
+ if (n?.kind !== "number" || Number(n.value) < 1) {
26532
+ this.error("Expected a whole number of shards, at least 1, e.g. shards 4", this.locOf(n ?? t, line));
26533
+ return;
26534
+ }
26535
+ override.shards = Number(n.value);
26536
+ i += 2;
26537
+ } else if (isWord(t, "consistency")) {
26538
+ part = "consistency";
26539
+ const value = tokens[i + 1];
26540
+ if (!isWord(value, "strong") && !isWord(value, "eventual")) {
26541
+ this.error(`Expected strong or eventual after consistency${value ? `, not ${describe(value)}` : ""}`, this.locOf(value ?? t, line));
26542
+ return;
26543
+ }
26544
+ override.consistency = value.value;
26545
+ i += 2;
26546
+ } else if (isWord(t, "bandwidth") || isWord(t, "egress")) {
26547
+ part = t.value;
26548
+ const value = this.quantityOf(tokens[i + 1], part === "bandwidth" ? "MBps" : "usd/GB", t, line);
26549
+ if (value === void 0) return;
26550
+ if (part === "bandwidth") {
26551
+ if (value <= 0) {
26552
+ this.error("Bandwidth must be more than 0", this.locOf(tokens[i + 1], line));
26553
+ return;
26554
+ }
26555
+ override.bandwidthMBps = value;
26556
+ } else override.egressUsdPerGb = value;
26557
+ i += 2;
26377
26558
  } else {
26378
- this.error(`Unexpected ${describe(t)}; expected a rate, latency, availability, cost, durable or volatile`, this.locOf(t, line));
26559
+ this.error(`Unexpected ${describe(t)}; expected ${CAPACITY_PARTS}`, this.locOf(t, line));
26560
+ return;
26561
+ }
26562
+ const clash = part === "rate" ? ["reads", "writes"].find((p) => seen.has(p)) : part === "reads" || part === "writes" ? seen.has("rate") ? part : void 0 : void 0;
26563
+ if (clash) {
26564
+ this.error(`A rate sets both reads and writes; give either a rate or reads and writes for '${id.value}'`, this.locOf(t, line));
26379
26565
  return;
26380
26566
  }
26381
26567
  if (seen.has(part)) {
@@ -26386,7 +26572,7 @@ var Parser = class {
26386
26572
  }
26387
26573
  const idLoc = this.locOf(id, line);
26388
26574
  if (seen.size === 0) {
26389
- this.warning(`Capacity line for '${id.value}' overrides nothing; add a rate, latency, availability, cost, durable or volatile`, idLoc);
26575
+ this.warning(`Capacity line for '${id.value}' overrides nothing; add ${CAPACITY_PARTS}`, idLoc);
26390
26576
  return;
26391
26577
  }
26392
26578
  const first = this.capacity.find((c) => c.node === id.value);
@@ -26396,7 +26582,14 @@ var Parser = class {
26396
26582
  }
26397
26583
  this.capacity.push(override);
26398
26584
  this.deferred.push(() => {
26399
- if (!this.nodes.has(id.value)) this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
26585
+ const node = this.nodes.get(id.value);
26586
+ if (!node) {
26587
+ this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
26588
+ return;
26589
+ }
26590
+ const storeOnly = ["shards", "consistency"].filter((p) => seen.has(p));
26591
+ if (storeOnly.length && !isDataStore(kindOf(node)))
26592
+ this.warning(`'${id.value}' is not a data store (${DATA_STORE_KINDS.join(", ")}); ${storeOnly.join(" and ")} only apply to data stores`, idLoc);
26400
26593
  });
26401
26594
  }
26402
26595
  /** `entity Url [in db] ["description"] {` */
@@ -26581,10 +26774,28 @@ var Parser = class {
26581
26774
  this.error(ASSERTION_HELP, this.locOf(first, line));
26582
26775
  return;
26583
26776
  }
26584
- const target = selector(0);
26777
+ const inUseCase = isWord(first, "in") && tokens[1]?.kind === "string" ? { name: tokens[1].value, loc: this.locOf(tokens[1], line) } : void 0;
26778
+ const target = selector(inUseCase ? 2 : 0);
26585
26779
  if (!target) return;
26586
- if (!isWord(tokens[target.next], "has")) {
26587
- this.error(ASSERTION_HELP, this.locOf(tokens[target.next] ?? first, line));
26780
+ const word = tokens[target.next];
26781
+ if (isWord(word, "calls") || isWord(word, "never")) {
26782
+ const never = word.value === "never";
26783
+ if (never && !expectWords(target.next + 1, ["calls"], "any service never calls any storage")) return;
26784
+ const to = selector(target.next + (never ? 2 : 1));
26785
+ if (!to) return;
26786
+ const assertion3 = done(
26787
+ { kind: "senderCalls", ...inUseCase ? { useCase: inUseCase.name } : {}, from: target.selector, to: to.selector, quantifier: never ? "never" : "some", loc },
26788
+ to.next
26789
+ );
26790
+ if (assertion3 && inUseCase) this.deferred.push(() => this.checkUseCase(inUseCase.name, inUseCase.loc));
26791
+ return assertion3;
26792
+ }
26793
+ if (inUseCase) {
26794
+ this.error(`Expected calls or never calls here, e.g. in "${inUseCase.name}" api calls db`, this.locOf(word ?? tokens[tokens.length - 1], line));
26795
+ return;
26796
+ }
26797
+ if (!isWord(word, "has")) {
26798
+ this.error(ASSERTION_HELP, this.locOf(word ?? first, line));
26588
26799
  return;
26589
26800
  }
26590
26801
  const i2 = target.next + 1;
@@ -26625,6 +26836,10 @@ var Parser = class {
26625
26836
  const then = selector(target.next + 1);
26626
26837
  if (!then) return;
26627
26838
  assertion2 = done({ kind: "before", useCase, ...withScenario, first: target.selector, then: then.selector, loc }, then.next);
26839
+ } else if (isWord(tokens[target.next], "after")) {
26840
+ const after2 = selector(target.next + 1);
26841
+ if (!after2) return;
26842
+ assertion2 = done({ kind: "after", useCase, ...withScenario, target: target.selector, after: after2.selector, loc }, after2.next);
26628
26843
  } else {
26629
26844
  assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
26630
26845
  }
@@ -26633,6 +26848,16 @@ var Parser = class {
26633
26848
  const target = selector(i + 3);
26634
26849
  if (!target) return;
26635
26850
  assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "every", loc }, target.next);
26851
+ } else if (isWord(verb, "never") && isWord(tokens[i + 1], "waits")) {
26852
+ if (!expectWords(i + 2, ["for"], `"${useCase}" never waits for any queue`)) return;
26853
+ const target = selector(i + 3);
26854
+ if (!target) return;
26855
+ assertion2 = done({ kind: "neverWaits", useCase, ...withScenario, target: target.selector, loc }, target.next);
26856
+ } else if (isWord(verb, "starts")) {
26857
+ if (!wholeUseCase("starts at") || !expectWords(i + 1, ["at"], `"${useCase}" starts at any queue`)) return;
26858
+ const target = selector(i + 2);
26859
+ if (!target) return;
26860
+ assertion2 = done({ kind: "startsAt", useCase, target: target.selector, loc }, target.next);
26636
26861
  } else if (isWord(verb, "never")) {
26637
26862
  if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
26638
26863
  const target = selector(i + 2);
@@ -26674,8 +26899,23 @@ var Parser = class {
26674
26899
  }
26675
26900
  return assertion2;
26676
26901
  }
26677
- /** A node id, `[Tech]` or `any <kind>` starting at `tokens[i]`. */
26902
+ /** A selector starting at `tokens[i]`: one or more of node id, `[Tech]`, `any <kind>`, `any strong store`, joined by `or`. */
26678
26903
  parseSelector(tokens, i, line) {
26904
+ const atoms = [];
26905
+ let next = i;
26906
+ for (; ; ) {
26907
+ const atom = this.parseSelectorAtom(tokens, next, line);
26908
+ if (!atom) return void 0;
26909
+ atoms.push(atom);
26910
+ next = atom.next;
26911
+ if (!isWord(tokens[next], "or")) break;
26912
+ next++;
26913
+ }
26914
+ const selector = atoms.length === 1 ? atoms[0].selector : { anyOf: atoms.map((a) => a.selector) };
26915
+ return { selector, next, atoms };
26916
+ }
26917
+ /** One node id, `[Tech]`, `any <kind>` or `any strong|eventual store` at `tokens[i]`. */
26918
+ parseSelectorAtom(tokens, i, line) {
26679
26919
  const t = tokens[i];
26680
26920
  if (t?.kind === "tech") {
26681
26921
  const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
@@ -26683,8 +26923,15 @@ var Parser = class {
26683
26923
  }
26684
26924
  if (isWord(t, "any")) {
26685
26925
  const kind = tokens[i + 1];
26926
+ if (isWord(kind, "strong") || isWord(kind, "eventual")) {
26927
+ if (!isWord(tokens[i + 2], "store")) {
26928
+ this.error(`Expected 'store' here, e.g. any ${kind.value} store`, this.locOf(tokens[i + 2] ?? kind, line));
26929
+ return void 0;
26930
+ }
26931
+ return { selector: { consistency: kind.value }, next: i + 3, token: t, loc: this.locOf(t, line) };
26932
+ }
26686
26933
  if (kind?.kind !== "ident" || !isKind(kind.value)) {
26687
- this.error(`Expected a kind after any: ${KINDS.join(", ")}`, this.locOf(kind ?? t, line));
26934
+ this.error(`Expected a kind after any: ${KINDS.join(", ")}, or strong store / eventual store`, this.locOf(kind ?? t, line));
26688
26935
  return void 0;
26689
26936
  }
26690
26937
  return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
@@ -26694,12 +26941,14 @@ var Parser = class {
26694
26941
  return void 0;
26695
26942
  }
26696
26943
  /** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
26697
- checkSelectorLater({ selector, token, loc }) {
26698
- if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
26699
- if ("node" in selector) {
26700
- this.deferred.push(() => {
26701
- if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
26702
- });
26944
+ checkSelectorLater({ atoms }) {
26945
+ for (const { selector, token, loc } of atoms) {
26946
+ if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
26947
+ if ("node" in selector) {
26948
+ this.deferred.push(() => {
26949
+ if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
26950
+ });
26951
+ }
26703
26952
  }
26704
26953
  }
26705
26954
  checkUseCase(name, loc) {
@@ -26713,10 +26962,10 @@ var Parser = class {
26713
26962
  this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
26714
26963
  }
26715
26964
  /** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
26716
- quantityOf(token, unit, after, line) {
26965
+ quantityOf(token, unit, after2, line) {
26717
26966
  const expected = UNIT_EXAMPLES[unit];
26718
26967
  if (token?.kind !== "quantity" && token?.kind !== "number") {
26719
- this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after, line));
26968
+ this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after2, line));
26720
26969
  return void 0;
26721
26970
  }
26722
26971
  const loc = this.locOf(token, line);
@@ -26819,7 +27068,10 @@ var Parser = class {
26819
27068
  isParallel: step.parallelGroup !== void 0,
26820
27069
  isConditional: false,
26821
27070
  ...step.arrow === "-x" ? { failed: true } : {},
26822
- loc: step.loc
27071
+ loc: step.loc,
27072
+ ...step.multiplier !== void 0 ? { multiplier: step.multiplier } : {},
27073
+ ...step.sizeBytes !== void 0 ? { sizeBytes: step.sizeBytes } : {},
27074
+ access: accessOf(label.method, step.label)
26823
27075
  });
26824
27076
  }
26825
27077
  return steps;
@@ -26985,6 +27237,16 @@ function parseStepLabel(input) {
26985
27237
  const payload = splitPayload(input);
26986
27238
  return { name: payload.text, format: payload.body ? payload.format : void 0, body: payload.body };
26987
27239
  }
27240
+ var SIZE_UNITS = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12 };
27241
+ function parseSize(text) {
27242
+ const m = /^(\d+(?:\.\d+)?)([A-Za-z]*)$/.exec(text);
27243
+ if (!m) return { error: `Expected a payload size after ~, e.g. ~2MB, ~500KB or ~1.5GB${text ? `, not '~${text}'` : ""}` };
27244
+ const factor = SIZE_UNITS[m[2]];
27245
+ if (factor === void 0) return { error: m[2] ? `Unknown size unit '${m[2]}' in '~${text}'; use B, KB, MB, GB or TB` : `'~${text}' needs a unit: B, KB, MB, GB or TB` };
27246
+ const value = Math.round(Number(m[1]) * factor);
27247
+ if (value <= 0) return { error: `A payload size must be more than 0, not '~${text}'` };
27248
+ return { value };
27249
+ }
26988
27250
  function inferProtocol(isAsync, method, target) {
26989
27251
  if (isAsync || target?.type === "queue") return "MESSAGING";
26990
27252
  if (!method) return "OTHER";
@@ -27215,8 +27477,8 @@ function alignConnections(run) {
27215
27477
  function commentOf(raw, tokens) {
27216
27478
  const last = tokens[tokens.length - 1];
27217
27479
  if (last.kind === "label") {
27218
- const after = raw.slice(last.col);
27219
- return after.slice(stripTrailingComment(after).length).trim();
27480
+ const after2 = raw.slice(last.col);
27481
+ return after2.slice(stripTrailingComment(after2).length).trim();
27220
27482
  }
27221
27483
  return raw.slice(last.col - 1 + last.length).trim();
27222
27484
  }
@@ -27384,6 +27646,104 @@ var RESERVED = new Set(
27384
27646
  ].map((w) => w.toLowerCase())
27385
27647
  );
27386
27648
 
27649
+ // ../frontend/src/sim/access.ts
27650
+ function stepAccess(step) {
27651
+ return step.access ?? accessOf(step.httpMethod, step.stepName ?? "");
27652
+ }
27653
+ function accessIn(diagram) {
27654
+ const queues = new Set(diagram.nodes.filter((n) => kindOf(n) === "queue").map((n) => n.id));
27655
+ return (step) => queues.has(step.toServiceId) ? "write" : stepAccess(step);
27656
+ }
27657
+
27658
+ // ../frontend/src/sim/profiles.ts
27659
+ var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
27660
+ var RELATIONAL = {
27661
+ rps: 2e4,
27662
+ readRps: 2e4,
27663
+ writeRps: 5e3,
27664
+ writeScaling: "shards",
27665
+ latencyMs: 5,
27666
+ availability: 0.9995,
27667
+ costUsd: 400,
27668
+ durable: true,
27669
+ consistency: "strong"
27670
+ };
27671
+ var KIND_PROFILES = {
27672
+ client: { ...NEUTRAL, bandwidthMBps: 10 },
27673
+ edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
27674
+ cdn: { rps: 2e5, latencyMs: 5, availability: 0.9999, costUsd: 100, durable: false, egressUsdPerGb: 0.02 },
27675
+ loadbalancer: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
27676
+ gateway: { rps: 1e4, latencyMs: 10, availability: 0.9995, costUsd: 100, durable: false },
27677
+ // Name resolution happens before the request and is cached: off the request path.
27678
+ dns: { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false },
27679
+ service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
27680
+ function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
27681
+ cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false, consistency: "eventual" },
27682
+ database: RELATIONAL,
27683
+ search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true, consistency: "eventual" },
27684
+ analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true, consistency: "eventual" },
27685
+ queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true, consistency: "strong" },
27686
+ storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true, consistency: "eventual", egressUsdPerGb: 0.09 },
27687
+ external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
27688
+ other: NEUTRAL
27689
+ };
27690
+ var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true, consistency: "eventual" };
27691
+ var NOSQL_STRONG = { ...NOSQL, consistency: "strong" };
27692
+ var TECH_PROFILES = {
27693
+ DynamoDB: NOSQL,
27694
+ "AWS DynamoDB": NOSQL,
27695
+ Cassandra: NOSQL,
27696
+ CouchDB: NOSQL,
27697
+ "Azure Cosmos DB": NOSQL,
27698
+ MongoDB: NOSQL_STRONG,
27699
+ "GCP Bigtable": NOSQL_STRONG,
27700
+ "GCP Firestore": NOSQL_STRONG,
27701
+ "GCP Spanner": NOSQL_STRONG
27702
+ };
27703
+ var BANDWIDTH = {
27704
+ client: 10,
27705
+ edge: 1e3,
27706
+ cdn: 1e3,
27707
+ loadbalancer: 1e3,
27708
+ gateway: 1e3,
27709
+ dns: 1e3,
27710
+ service: 200
27711
+ };
27712
+ var DEFAULT_BANDWIDTH_MBPS = 100;
27713
+ function profileOf(node, override) {
27714
+ const kind = kindOf(node);
27715
+ const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
27716
+ const baseRead = base.readRps ?? base.rps;
27717
+ const baseWrite = base.writeRps ?? base.rps;
27718
+ const readRps = override?.readRps ?? override?.rps ?? baseRead;
27719
+ const writeRps = override?.writeRps ?? override?.rps ?? baseWrite;
27720
+ const consistency = override?.consistency ?? base.consistency;
27721
+ return {
27722
+ kind,
27723
+ rps: readRps,
27724
+ readRps,
27725
+ writeRps,
27726
+ writeScaling: base.writeScaling ?? "replicas",
27727
+ shards: Math.max(1, Math.floor(override?.shards ?? 1)),
27728
+ latencyMs: override?.latencyMs ?? base.latencyMs,
27729
+ availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
27730
+ costUsd: override?.costUsd ?? base.costUsd,
27731
+ durable: override?.durable ?? base.durable,
27732
+ ...consistency && isDataStore(kind) ? { consistency } : {},
27733
+ bandwidthMBps: override?.bandwidthMBps ?? base.bandwidthMBps ?? BANDWIDTH[kind] ?? DEFAULT_BANDWIDTH_MBPS,
27734
+ egressUsdPerGb: override?.egressUsdPerGb ?? base.egressUsdPerGb ?? 0
27735
+ };
27736
+ }
27737
+ function capacityOf(p, replicas) {
27738
+ return {
27739
+ readRps: p.readRps * replicas * p.shards,
27740
+ writeRps: p.writeRps * (p.writeScaling === "shards" ? 1 : replicas) * p.shards
27741
+ };
27742
+ }
27743
+ function combinedUtilization(p, readUtilization, writeUtilization) {
27744
+ return p.writeScaling === "shards" ? Math.max(readUtilization, writeUtilization) : readUtilization + writeUtilization;
27745
+ }
27746
+
27387
27747
  // ../frontend/src/sim/flow.ts
27388
27748
  function findUseCase(diagram, name) {
27389
27749
  const lower = name.toLowerCase();
@@ -27448,9 +27808,9 @@ function requestOrder(useCase, scenario) {
27448
27808
  }
27449
27809
  return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
27450
27810
  }
27451
- function writesBeforeResponse(useCase, scenario, accept) {
27811
+ function writesBeforeResponse(useCase, scenario, accept, isWrite = (step) => stepAccess(step) === "write") {
27452
27812
  const { requests, beforeResponse } = requestOrder(useCase, scenario);
27453
- return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to));
27813
+ return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to) && isWrite(m.step));
27454
27814
  }
27455
27815
  function callsOk(scenario, nodeId) {
27456
27816
  return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
@@ -27459,23 +27819,34 @@ function needs(useCase, nodeId) {
27459
27819
  return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
27460
27820
  }
27461
27821
  function fallbacksFor(useCase, nodeId) {
27462
- return useCase.scenarios.filter(
27463
- (s) => s.outcome === "success" && s.steps.some((step) => step.toServiceId === nodeId && step.failed) && !callsOk(s, nodeId)
27464
- );
27822
+ return useCase.scenarios.filter((s) => {
27823
+ if (s.outcome !== "success") return false;
27824
+ const { requests, beforeResponse } = requestOrder(useCase, s);
27825
+ const early = requests.slice(0, beforeResponse).filter((m) => m.to === nodeId);
27826
+ const window = early.some((m) => m.failed) ? early : requests.filter((m) => m.to === nodeId);
27827
+ return window.some((m) => m.failed) && !window.some((m) => !m.failed && !m.async);
27828
+ });
27465
27829
  }
27466
27830
  function selectorText(selector) {
27467
27831
  if ("node" in selector) return selector.node;
27468
27832
  if ("tech" in selector) return `[${selector.tech}]`;
27833
+ if ("anyOf" in selector) return selector.anyOf.map(selectorText).join(" or ");
27834
+ if ("consistency" in selector) return `any ${selector.consistency} store`;
27469
27835
  return `any ${selector.kind}`;
27470
27836
  }
27471
27837
  function selectNodes(diagram, selector) {
27472
- return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector));
27838
+ return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector, diagram));
27473
27839
  }
27474
- function matches(node, selector) {
27840
+ function matches(node, selector, diagram) {
27475
27841
  if (node.kind !== "component") return false;
27476
27842
  if ("node" in selector) return node.id === selector.node;
27477
27843
  if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
27478
- return kindOf(node) === selector.kind;
27844
+ if ("anyOf" in selector) return selector.anyOf.some((s) => matches(node, s, diagram));
27845
+ if ("consistency" in selector) {
27846
+ const override = diagram?.capacity?.find((o) => o.node === node.id);
27847
+ return profileOf(node, override).consistency === selector.consistency;
27848
+ }
27849
+ return kindMatches(kindOf(node), selector.kind);
27479
27850
  }
27480
27851
  function techOf(node) {
27481
27852
  return node && !node.implicit ? node.techStack : void 0;
@@ -27509,51 +27880,13 @@ function formatUsd(usd) {
27509
27880
  return `$${Math.round(usd).toLocaleString("en-US")}/month`;
27510
27881
  }
27511
27882
 
27512
- // ../frontend/src/sim/profiles.ts
27513
- var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
27514
- var KIND_PROFILES = {
27515
- client: NEUTRAL,
27516
- edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
27517
- service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
27518
- function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
27519
- cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false },
27520
- database: { rps: 5e3, latencyMs: 5, availability: 0.9995, costUsd: 400, durable: true },
27521
- search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true },
27522
- analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true },
27523
- queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true },
27524
- storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true },
27525
- external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
27526
- other: NEUTRAL
27527
- };
27528
- var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true };
27529
- var TECH_PROFILES = {
27530
- DynamoDB: NOSQL,
27531
- "AWS DynamoDB": NOSQL,
27532
- Cassandra: NOSQL,
27533
- MongoDB: NOSQL,
27534
- CouchDB: NOSQL,
27535
- "Azure Cosmos DB": NOSQL,
27536
- "GCP Bigtable": NOSQL,
27537
- "GCP Firestore": NOSQL,
27538
- "GCP Spanner": NOSQL
27539
- };
27540
- function profileOf(node, override) {
27541
- const kind = kindOf(node);
27542
- const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
27543
- return {
27544
- kind,
27545
- rps: override?.rps ?? base.rps,
27546
- latencyMs: override?.latencyMs ?? base.latencyMs,
27547
- availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
27548
- costUsd: override?.costUsd ?? base.costUsd,
27549
- durable: override?.durable ?? base.durable
27550
- };
27551
- }
27552
-
27553
27883
  // ../frontend/src/sim/analyze.ts
27554
27884
  var DEFAULT_TIMEOUT_MS = 1e3;
27555
27885
  var HOT = 0.7;
27556
27886
  var QUEUE_CAP = 0.95;
27887
+ var SECONDS_PER_MONTH = 2592e3;
27888
+ var BYTES_PER_MB = 1e6;
27889
+ var BYTES_PER_GB = 1e9;
27557
27890
  var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
27558
27891
  var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
27559
27892
  var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
@@ -27589,20 +27922,73 @@ function resolveTraffic(diagram, warnings = []) {
27589
27922
  }
27590
27923
  return result;
27591
27924
  }
27592
- function computeLoad(diagram, traffic) {
27593
- const load = /* @__PURE__ */ new Map();
27925
+ var multiplierOf = (step) => step.multiplier !== void 0 && step.multiplier > 0 ? step.multiplier : 1;
27926
+ function forEachStepRate(diagram, traffic, visit) {
27594
27927
  for (const u of diagram.useCases) {
27595
27928
  const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
27596
27929
  u.scenarios.forEach((s, i) => {
27597
27930
  const rate = rps * (shares[i] ?? 0);
27598
- if (rate > 0) for (const step of s.steps) load.set(step.toServiceId, (load.get(step.toServiceId) ?? 0) + rate);
27931
+ if (rate > 0) for (const step of s.steps) visit(step, rate);
27599
27932
  });
27600
27933
  }
27934
+ }
27935
+ function computeLoad(diagram, traffic) {
27936
+ const load = /* @__PURE__ */ new Map();
27937
+ const access = accessIn(diagram);
27938
+ forEachStepRate(diagram, traffic, (step, rate) => {
27939
+ const entry = load.get(step.toServiceId) ?? { read: 0, write: 0 };
27940
+ entry[access(step)] += rate * multiplierOf(step);
27941
+ load.set(step.toServiceId, entry);
27942
+ });
27601
27943
  return load;
27602
27944
  }
27945
+ var egressSource = (step, access) => access === "read" ? step.toServiceId : step.fromServiceId;
27946
+ function computeEgressGb(diagram, traffic) {
27947
+ const gb = /* @__PURE__ */ new Map();
27948
+ const access = accessIn(diagram);
27949
+ forEachStepRate(diagram, traffic, (step, rate) => {
27950
+ if (!step.sizeBytes || step.sizeBytes <= 0) return;
27951
+ const source2 = egressSource(step, access(step));
27952
+ gb.set(source2, (gb.get(source2) ?? 0) + rate * multiplierOf(step) * step.sizeBytes * SECONDS_PER_MONTH / BYTES_PER_GB);
27953
+ });
27954
+ return gb;
27955
+ }
27956
+ function transferMs(step, bandwidthOf) {
27957
+ if (!step.sizeBytes || step.sizeBytes <= 0) return 0;
27958
+ const mbps = Math.min(bandwidthOf(step.fromServiceId), bandwidthOf(step.toServiceId));
27959
+ return mbps > 0 && Number.isFinite(mbps) ? step.sizeBytes / BYTES_PER_MB / mbps * 1e3 : 0;
27960
+ }
27603
27961
  var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
27604
27962
  var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
27605
27963
  var replicatedAvailability = (a, replicas) => 1 - (1 - a) ** replicas;
27964
+ function usageOf(p, replicas, readLoadRps, writeLoadRps) {
27965
+ const { readRps: readCapacityRps, writeRps: writeCapacityRps } = capacityOf(p, replicas);
27966
+ const readUtilization = utilizationOf(readLoadRps, readCapacityRps);
27967
+ const writeUtilization = utilizationOf(writeLoadRps, writeCapacityRps);
27968
+ const utilization = combinedUtilization(p, readUtilization, writeUtilization);
27969
+ const loadRps = readLoadRps + writeLoadRps;
27970
+ return {
27971
+ loadRps,
27972
+ capacityRps: readCapacityRps === writeCapacityRps || utilization === 0 ? readCapacityRps : loadRps / utilization,
27973
+ utilization,
27974
+ saturated: utilization >= 1,
27975
+ readLoadRps,
27976
+ writeLoadRps,
27977
+ readCapacityRps,
27978
+ writeCapacityRps,
27979
+ readUtilization,
27980
+ writeUtilization,
27981
+ writeScaling: p.writeScaling
27982
+ };
27983
+ }
27984
+ function usageText(n) {
27985
+ const part = (load, capacity, rho) => `${formatRps(load)} of ${formatRps(capacity)}, ${formatPercent(rho)}`;
27986
+ if (n.writeScaling === "shards" && n.writeLoadRps > 0) {
27987
+ return `reads ${part(n.readLoadRps, n.readCapacityRps, n.readUtilization)}; writes ${part(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization)}`;
27988
+ }
27989
+ return part(n.loadRps, n.capacityRps, n.utilization);
27990
+ }
27991
+ var writeBound = (n) => n.writeScaling === "shards" && n.writeUtilization >= n.readUtilization && n.writeLoadRps > 0;
27606
27992
  function useCasePercentile(scenarios, q) {
27607
27993
  const tail = 1 - q / 100 - 1e-9;
27608
27994
  const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
@@ -27621,49 +28007,57 @@ function mainScenarioIndex(shares) {
27621
28007
  });
27622
28008
  return best;
27623
28009
  }
28010
+ function profilesOf(diagram, warnings = []) {
28011
+ const nodes = components(diagram);
28012
+ const ids = new Set(nodes.map((n) => n.id));
28013
+ const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
28014
+ for (const o of diagram.capacity ?? []) if (!ids.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
28015
+ return new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
28016
+ }
27624
28017
  function analyze(diagram, options = {}) {
27625
28018
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
27626
28019
  const warnings = [];
27627
28020
  const nodes = components(diagram);
27628
- const byId = new Map(nodes.map((n) => [n.id, n]));
27629
- const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
27630
- for (const o of diagram.capacity ?? []) if (!byId.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
27631
- const profiles = new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
28021
+ const profiles = profilesOf(diagram, warnings);
27632
28022
  const traffic = resolveTraffic(diagram, warnings);
27633
28023
  const load = computeLoad(diagram, traffic);
28024
+ const egress = computeEgressGb(diagram, traffic);
27634
28025
  const nodeResults = nodes.map((n) => {
27635
28026
  const p = profiles.get(n.id);
27636
28027
  const replicas = replicasOf(n);
27637
- const loadRps = load.get(n.id) ?? 0;
27638
- const capacityRps = p.rps * replicas;
27639
- const utilization = utilizationOf(loadRps, capacityRps);
28028
+ const { read, write } = load.get(n.id) ?? { read: 0, write: 0 };
28029
+ const usage = usageOf(p, replicas, read, write);
28030
+ const egressGbPerMonth = egress.get(n.id) ?? 0;
28031
+ const egressUsd = egressGbPerMonth * p.egressUsdPerGb;
27640
28032
  return {
27641
28033
  id: n.id,
27642
28034
  kind: p.kind,
27643
28035
  replicas,
27644
- loadRps,
27645
- capacityRps,
27646
- utilization,
27647
- saturated: utilization >= 1,
27648
- latencyMs: hopLatency(p.latencyMs, utilization),
28036
+ shards: p.shards,
28037
+ ...usage,
28038
+ ...p.consistency ? { consistency: p.consistency } : {},
28039
+ latencyMs: hopLatency(p.latencyMs, usage.utilization),
27649
28040
  availability: replicatedAvailability(p.availability, replicas),
27650
- costUsd: replicas * p.costUsd,
28041
+ costUsd: replicas * p.shards * p.costUsd + egressUsd,
28042
+ egressGbPerMonth,
28043
+ egressUsd,
27651
28044
  durable: p.durable
27652
28045
  };
27653
28046
  });
27654
28047
  const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
27655
28048
  const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
27656
28049
  const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
28050
+ const bandwidthOf = (id) => profiles.get(id)?.bandwidthMBps ?? Infinity;
27657
28051
  for (const n of nodeResults) {
27658
- const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
27659
- if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. Add replicas or take load off it.`);
28052
+ const usage = usageText(n);
28053
+ if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. ${writeBound(n) ? "Add shards for writes" : "Add replicas"} or take load off it.`);
27660
28054
  else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
27661
28055
  }
27662
28056
  const useCases = diagram.useCases.map((u) => {
27663
28057
  const { rps, shares } = traffic.get(u.id);
27664
28058
  const scenarios = u.scenarios.map((s, i) => {
27665
28059
  const meanMs = criticalPath(u, s).reduce(
27666
- (sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target))),
28060
+ (sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target) + transferMs(hop.step, bandwidthOf))),
27667
28061
  0
27668
28062
  );
27669
28063
  return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
@@ -27678,6 +28072,7 @@ function analyze(diagram, options = {}) {
27678
28072
  nodes: nodeResults,
27679
28073
  useCases,
27680
28074
  totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
28075
+ totalEgressUsd: nodeResults.reduce((sum, n) => sum + n.egressUsd, 0),
27681
28076
  singlePointsOfFailure: singlePointsOfFailure(diagram),
27682
28077
  warnings
27683
28078
  };
@@ -27702,7 +28097,7 @@ function useCaseAvailability(useCase, shares, availabilityOf) {
27702
28097
  }
27703
28098
  return result;
27704
28099
  }
27705
- var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external";
28100
+ var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external" && kind !== "dns";
27706
28101
  function singlePointsOfFailure(diagram) {
27707
28102
  return components(diagram).filter((n) => replicasOf(n) === 1 && DEPLOYED(profileOf(n).kind)).filter((n) => diagram.useCases.some((u) => needs(u, n.id) && fallbacksFor(u, n.id).length === 0)).map((n) => n.id);
27708
28103
  }
@@ -27711,20 +28106,20 @@ function singlePointsOfFailure(diagram) {
27711
28106
  var pass = (message) => ({ passed: true, message });
27712
28107
  var fail = (message, hint) => ({ passed: false, message, hint });
27713
28108
  function runTests(diagram, analysis = analyze(diagram)) {
28109
+ const access = accessIn(diagram);
27714
28110
  const run = {
27715
28111
  diagram,
27716
28112
  analysis,
27717
28113
  nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
27718
28114
  useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
27719
- shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares]))
28115
+ shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares])),
28116
+ profiles: profilesOf(diagram),
28117
+ isWrite: (step) => access(step) === "write"
27720
28118
  };
27721
- const results = (diagram.requirements ?? []).map((r, i) => ({
27722
- id: `req:${i + 1}`,
27723
- name: requirementName(r),
27724
- category: CATEGORY[r.kind],
27725
- ...requirement(run, r),
27726
- loc: r.loc
27727
- }));
28119
+ const results = (diagram.requirements ?? []).map((r, i) => {
28120
+ const { passed, message, hint } = requirement(run, r);
28121
+ return { id: `req:${i + 1}`, name: requirementName(r), category: CATEGORY[r.kind], passed, message, hint, loc: r.loc };
28122
+ });
27728
28123
  const names = /* @__PURE__ */ new Map();
27729
28124
  for (const test of diagram.tests ?? []) {
27730
28125
  const n = (names.get(test.name) ?? 0) + 1;
@@ -27741,10 +28136,16 @@ var CATEGORY = {
27741
28136
  cost: "cost"
27742
28137
  };
27743
28138
  var percentileName = (q) => PERCENTILE_KEYS[q];
28139
+ var lineOf = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
28140
+ function stepText(step) {
28141
+ const arrow = step.failed ? "-x" : step.executionType === "SYNC_REQUEST_RESPONSE" ? "->" : "->>";
28142
+ const label = requestLabel(step);
28143
+ return `${step.fromServiceId} ${arrow} ${step.toServiceId}${label ? ` : ${label}` : ""} at ${lineOf(step.loc)}`;
28144
+ }
27744
28145
  function requirementName(r) {
27745
28146
  switch (r.kind) {
27746
28147
  case "latency":
27747
- return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"} < ${formatMs(r.maxMs)}`;
28148
+ return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"}${r.scenario !== void 0 ? ` scenario ${r.scenario}` : ""} < ${formatMs(r.maxMs)}`;
27748
28149
  case "availability":
27749
28150
  return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
27750
28151
  case "durable":
@@ -27758,6 +28159,7 @@ function requirementName(r) {
27758
28159
  function requirement(run, r) {
27759
28160
  switch (r.kind) {
27760
28161
  case "latency":
28162
+ if (r.useCase !== void 0 && r.scenario !== void 0) return scenarioLatency(run, r.useCase, r.scenario, r.percentile, r.maxMs);
27761
28163
  return overUseCases(run, r.useCase, (u) => latency(run, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
27762
28164
  case "availability":
27763
28165
  return overUseCases(run, r.useCase, (u) => availability(run, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
@@ -27786,43 +28188,65 @@ function overUseCases(run, name, check, limit, anyWithoutTraffic = false) {
27786
28188
  }
27787
28189
  function unknownUseCase(run, name) {
27788
28190
  const known = run.diagram.useCases.map((u) => `"${u.name}"`);
27789
- return fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block');
28191
+ return { ...fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block'), missing: `use case ${name}` };
28192
+ }
28193
+ function saturatedOn(run, ids, value, maxMs) {
28194
+ const n = [...new Set(ids)].map((id) => run.nodes.get(id)).find((x) => !!x?.saturated);
28195
+ if (!n) return void 0;
28196
+ const v = value(n);
28197
+ return { ...fail(`${v} (limit ${formatMs(maxMs)})`, saturationHint(run, n)), value: v };
27790
28198
  }
27791
28199
  function latency(run, u, q, maxMs) {
27792
28200
  const result = run.useCases.get(u.id);
27793
28201
  const key = percentileName(q);
27794
28202
  const shares = run.shares.get(u.id) ?? [];
27795
- const touched = new Set(u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []));
27796
- const saturated = [...touched].map((id) => run.nodes.get(id)).filter((n) => !!n?.saturated);
27797
- if (saturated.length) {
27798
- const n = saturated[0];
27799
- const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
27800
- const value2 = `${key} of ${u.name}: ${n.id} is saturated (${usage})`;
27801
- return { ...fail(`${value2} (limit ${formatMs(maxMs)})`, saturationHint(run, n)), value: value2 };
27802
- }
28203
+ const touched = u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []);
28204
+ const saturated = saturatedOn(run, touched, (n) => `${key} of ${u.name}: ${n.id} is saturated (${usageText(n)})`, maxMs);
28205
+ if (saturated) return saturated;
27803
28206
  const ms = result.percentiles[key];
27804
28207
  const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
27805
28208
  const message = `${value} (limit ${formatMs(maxMs)})`;
27806
- return ms < maxMs ? { ...pass(message), value } : { ...fail(message, latencyHint(run, u, result, key, ms)), value };
28209
+ if (ms < maxMs) return { ...pass(message), value };
28210
+ const index = Math.max(0, result.scenarios.findIndex((s) => s.percentiles[key] === ms));
28211
+ const lead = u.scenarios.length > 1 ? `The "${u.scenarios[index].name}" path (${formatPercent(result.scenarios[index].share)} of traffic) sets ${key}. ` : "";
28212
+ return { ...fail(message, lead + latencyHint(run, u, u.scenarios[index])), value };
28213
+ }
28214
+ function scenarioLatency(run, useCase, scenario, q, maxMs) {
28215
+ const sc = scope(run, useCase, scenario);
28216
+ if (isCheck(sc)) return sc;
28217
+ const s = sc.scenarios[0];
28218
+ const key = percentileName(q);
28219
+ const what = `${key} of "${sc.u.name}" scenario "${s.name}"`;
28220
+ const saturated = saturatedOn(run, s.steps.map((step) => step.toServiceId), (n) => `${what}: ${n.id} is saturated (${usageText(n)})`, maxMs);
28221
+ if (saturated) return saturated;
28222
+ const result = run.useCases.get(sc.u.id)?.scenarios[sc.u.scenarios.indexOf(s)];
28223
+ const ms = result?.percentiles[key] ?? 0;
28224
+ const message = `${what} is ${formatMs(ms)} (limit ${formatMs(maxMs)})`;
28225
+ return ms < maxMs ? pass(message) : fail(message, latencyHint(run, sc.u, s));
27807
28226
  }
27808
28227
  function saturationHint(run, n) {
27809
- const perReplica = n.capacityRps / n.replicas;
27810
- const replicas = Math.ceil(n.loadRps / (perReplica * HOT));
27811
- return `Add replicas to ${nodeLabel(run.diagram, n.id)}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
28228
+ const label = nodeLabel(run.diagram, n.id);
28229
+ if (writeBound(n)) {
28230
+ const perShard = n.writeCapacityRps / n.shards;
28231
+ const shards = Math.ceil(n.writeLoadRps / (perShard * HOT));
28232
+ return `Writes to ${label} need ${formatRps(n.writeLoadRps)} and each primary takes ${formatRps(perShard)}; read replicas do not add write capacity. Add shards (capacity { ${n.id} shards ${shards} } keeps writes under ${formatPercent(HOT)}), move the data to a partitioned store (DynamoDB, Cassandra), or batch writes through a queue`;
28233
+ }
28234
+ const rho = n.writeScaling === "shards" ? n.readUtilization : n.utilization;
28235
+ const replicas = Math.ceil(rho * n.replicas / HOT);
28236
+ return `Add replicas to ${label}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
27812
28237
  }
27813
- function latencyHint(run, u, result, key, ms) {
27814
- const index = result.scenarios.findIndex((s) => s.percentiles[key] === ms);
27815
- const scenario = u.scenarios[Math.max(0, index)];
27816
- const lead = u.scenarios.length > 1 ? `The "${scenario.name}" path (${formatPercent(result.scenarios[Math.max(0, index)].share)} of traffic) sets ${key}. ` : "";
28238
+ function latencyHint(run, u, scenario) {
27817
28239
  const hops = criticalPath(u, scenario).flat();
27818
28240
  const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run.nodes.get(h.target)?.latencyMs ?? 0;
27819
28241
  const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
27820
- if (!slowest) return `${lead}Shorten the synchronous path of the request.`;
28242
+ if (!slowest) return "Shorten the synchronous path of the request.";
27821
28243
  const label = nodeLabel(run.diagram, slowest.target);
27822
- 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.`;
28244
+ if (slowest.failed) return `It waits ${formatMs(DEFAULT_TIMEOUT_MS)} for the failed call to ${label}; give that path a smaller share of traffic or avoid the call.`;
28245
+ const sized = hops.find((h) => (h.step.sizeBytes ?? 0) > 0);
27823
28246
  const node = run.nodes.get(slowest.target);
27824
- if (node && node.utilization > HOT) return `${lead}${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
27825
- 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 ->>.`;
28247
+ if (node && node.utilization > HOT) return `${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
28248
+ const payload = sized ? ` Payloads add transfer time too (${stepText(sized.step)}): send fewer bytes or let the client fetch them from a CDN or storage directly.` : "";
28249
+ return `Its slowest hop is ${label} at ${formatMs(cost2(slowest))}: put a cache in front of it, run independent calls in par, or move work off the request path with ->>.${payload}`;
27826
28250
  }
27827
28251
  function availability(run, u, minPercent) {
27828
28252
  const result = run.useCases.get(u.id);
@@ -27835,37 +28259,53 @@ function availability(run, u, minPercent) {
27835
28259
  const hint = weakest ? `The weakest link is ${nodeLabel(run.diagram, weakest.id)} at ${formatAvailability(weakest.availability)}: add a replica (x${weakest.replicas + 1}) or a fallback scenario that calls it with -x and still completes` : "Add replicas to the nodes on the main path, or fallback scenarios that complete without them";
27836
28260
  return { ...fail(message, hint), value };
27837
28261
  }
27838
- function writeGap(u, s, accept) {
28262
+ function writeGap(run, u, s, accept) {
27839
28263
  const { requests, beforeResponse } = requestOrder(u, s);
27840
- const writes = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
27841
- if (writes.some(({ m, i }) => m.async && i < beforeResponse)) return "async";
27842
- if (writes.some(({ i }) => i >= beforeResponse)) return "after";
27843
- return "none";
28264
+ const hits = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
28265
+ const writes = hits.filter(({ m }) => run.isWrite(m.step));
28266
+ const early = writes.find(({ m, i }) => m.async && i < beforeResponse);
28267
+ if (early) return { kind: "async", step: early.m.step };
28268
+ const late = writes.find(({ i }) => i >= beforeResponse);
28269
+ if (late) return { kind: "after", step: late.m.step };
28270
+ const read = hits.find(({ m, i }) => i < beforeResponse && !m.async);
28271
+ if (read) return { kind: "read", step: read.m.step };
28272
+ return { kind: "none" };
27844
28273
  }
27845
- var GAP_TEXT = {
27846
- async: "writes only asynchronously (->>)",
27847
- after: "writes only after responding",
27848
- none: "never writes"
27849
- };
27850
- function writesCheck(u, scenarios, accept, what) {
28274
+ function writesCheck(run, u, scenarios, accept, what) {
27851
28275
  const success = scenarios.filter((s) => s.outcome === "success");
27852
28276
  if (success.length === 0) return fail(`"${u.name}" has no success scenario to check`, "Add a scenario whose entry request is answered with a 2xx");
27853
- const gaps = success.map((s) => ({ s, ok: writesBeforeResponse(u, s, accept) })).filter((x) => !x.ok);
28277
+ const gaps = success.filter((s) => !writesBeforeResponse(u, s, accept, run.isWrite));
27854
28278
  if (gaps.length === 0) {
27855
28279
  const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
27856
- return pass(`${label} writes ${what} before responding`);
28280
+ return pass(`${label} writes to ${what} before responding`);
27857
28281
  }
27858
- const details = gaps.map(({ s }) => ({ s, gap: writeGap(u, s, accept) }));
28282
+ const details = gaps.map((s) => ({ s, gap: writeGap(run, u, s, accept) }));
27859
28283
  const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
27860
- const message = details.map(({ s, gap: gap2 }) => `${where(s)} ${GAP_TEXT[gap2]} ${what} before responding`).join("; ");
27861
- const gap = details[0].gap;
27862
- 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`;
27863
- return fail(message, hint);
28284
+ const text = (s, gap) => {
28285
+ switch (gap.kind) {
28286
+ case "async":
28287
+ return `${where(s)} writes to ${what} only asynchronously: ${stepText(gap.step)}`;
28288
+ case "after":
28289
+ return `${where(s)} writes to ${what} only after responding: ${stepText(gap.step)}`;
28290
+ case "read":
28291
+ return `${where(s)} only reads from ${what} before responding: ${stepText(gap.step)} is a read`;
28292
+ case "none":
28293
+ return `${where(s)} never writes to ${what} before responding`;
28294
+ }
28295
+ };
28296
+ const message = details.map(({ s, gap }) => text(s, gap)).join("; ");
28297
+ const hints = {
28298
+ async: "Make the write a synchronous request (->) so it is acknowledged before the response",
28299
+ after: "Move the write before the step that answers the entry request",
28300
+ read: "Only writes count: start the label with a write verb (INSERT, UPDATE, PUT, SET, \u2026) or use POST, PUT, PATCH or DELETE if the step stores data",
28301
+ none: `Add a synchronous write (->) to ${what} before the entry request is answered`
28302
+ };
28303
+ return fail(message, hints[details[0].gap.kind]);
27864
28304
  }
27865
28305
  function durable(run, name) {
27866
28306
  const u = findUseCase(run.diagram, name);
27867
28307
  if (!u) return unknownUseCase(run, name);
27868
- return writesCheck(u, u.scenarios, (id) => !!run.nodes.get(id)?.durable, "to a durable store");
28308
+ return writesCheck(run, u, u.scenarios, (id) => !!run.nodes.get(id)?.durable, "a durable store");
27869
28309
  }
27870
28310
  function survive(run, target) {
27871
28311
  const all = components(run.diagram);
@@ -27880,13 +28320,12 @@ function survive(run, target) {
27880
28320
  const label = nodeLabel(run.diagram, node.id);
27881
28321
  const replicas = replicasOf(node);
27882
28322
  if (replicas >= 2) {
27883
- const left = n.capacityRps / replicas * (replicas - 1);
27884
- const rho = n.loadRps / left;
27885
- if (rho >= 1) {
28323
+ const left = usageOf(run.profiles.get(node.id), replicas - 1, n.readLoadRps, n.writeLoadRps);
28324
+ if (left.saturated) {
27886
28325
  failures.push(
27887
28326
  fail(
27888
- `Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left)} for ${formatRps(n.loadRps)} (${formatPercent(rho)})`,
27889
- `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
28327
+ `Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left.capacityRps)} for ${formatRps(n.loadRps)} (${formatPercent(left.utilization)})`,
28328
+ writeBound(left) ? saturationHint(run, { ...n, ...left }) : `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
27890
28329
  )
27891
28330
  );
27892
28331
  }
@@ -27911,20 +28350,34 @@ function survive(run, target) {
27911
28350
  return fail(`${failures[0].message}${more}`, failures[0].hint);
27912
28351
  }
27913
28352
  function cost(run, max) {
27914
- const total = run.analysis.totalCostUsd;
27915
- const message = `Total cost is ${formatUsd(total)} (limit ${formatUsd(max)})`;
28353
+ const { totalCostUsd: total, totalEgressUsd: egress } = run.analysis;
28354
+ const ofWhich = egress > 0 ? `, ${formatUsd(egress)} of it egress` : "";
28355
+ const message = `Total cost is ${formatUsd(total)}${ofWhich} (limit ${formatUsd(max)})`;
27916
28356
  if (total <= max) return pass(message);
27917
- const top = [...run.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)` : ""}`);
27918
- return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.`);
28357
+ const usd = (x) => formatUsd(x).replace("/month", "");
28358
+ const top = [...run.analysis.nodes].filter((n) => n.costUsd > 0).sort((a, b) => b.costUsd - a.costUsd).slice(0, 3).map((n) => {
28359
+ const notes = [n.replicas > 1 ? `${n.replicas} replicas` : "", n.shards > 1 ? `${n.shards} shards` : "", n.egressUsd > 0 ? `${usd(n.egressUsd)} of it egress` : ""].filter(Boolean);
28360
+ return `${n.id} ${formatUsd(n.costUsd)}${notes.length ? ` (${notes.join(", ")})` : ""}`;
28361
+ });
28362
+ const egressHint = egress > total / 2 ? " Most of it is egress: serve repeated downloads from a CDN ($0.02/GB) instead of storage ($0.09/GB), or send fewer bytes." : "";
28363
+ return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.${egressHint}`);
27919
28364
  }
27920
28365
  function flowTest(run, test) {
27921
- const assertions = test.assertions.map((a) => ({ ...assertion(run, a), loc: a.loc }));
27922
- const failed = assertions.filter((a) => !a.passed);
28366
+ const checks = test.assertions.map((a) => ({ ...assertion(run, a), loc: a.loc }));
28367
+ const assertions = checks.map(({ passed, message, hint, loc }) => hint === void 0 ? { passed, message, loc } : { passed, message, hint, loc });
27923
28368
  if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
28369
+ const failed = checks.filter((a) => !a.passed);
27924
28370
  if (failed.length === 0) {
27925
28371
  return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
27926
28372
  }
27927
- return { passed: false, message: failed.map((a) => a.message).join("; "), hint: failed[0].hint, assertions };
28373
+ const seen = /* @__PURE__ */ new Set();
28374
+ const reported = failed.filter((a) => {
28375
+ const key = a.missing ?? a.message;
28376
+ if (seen.has(key)) return false;
28377
+ seen.add(key);
28378
+ return true;
28379
+ });
28380
+ return { passed: false, message: reported.map((a) => a.message).join("; "), hint: reported[0].hint, assertions };
27928
28381
  }
27929
28382
  var quote = (s) => `"${s}"`;
27930
28383
  function scope(run, useCase, scenario) {
@@ -27937,9 +28390,13 @@ function scope(run, useCase, scenario) {
27937
28390
  }
27938
28391
  function missingScenario(u, scenario) {
27939
28392
  const names = u.scenarios.map((s) => quote(s.name)).join(", ");
27940
- return fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`);
28393
+ return {
28394
+ ...fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`),
28395
+ missing: `scenario ${u.name} \u203A ${scenario}`
28396
+ };
27941
28397
  }
27942
28398
  var isCheck = (x) => "passed" in x;
28399
+ var idsOf = (run, selector) => new Set(selectNodes(run.diagram, selector).map((n) => n.id));
27943
28400
  function assertion(run, a) {
27944
28401
  switch (a.kind) {
27945
28402
  case "calls":
@@ -27949,9 +28406,9 @@ function assertion(run, a) {
27949
28406
  case "writesBeforeResponding": {
27950
28407
  const sc = scope(run, a.useCase, a.scenario);
27951
28408
  if (isCheck(sc)) return sc;
27952
- const ids = new Set(selectNodes(run.diagram, a.target).map((n) => n.id));
28409
+ const ids = idsOf(run, a.target);
27953
28410
  if (ids.size === 0) return noMatch(a.target);
27954
- return writesCheck(sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
28411
+ return writesCheck(run, sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
27955
28412
  }
27956
28413
  case "responds":
27957
28414
  return responds(run, a);
@@ -27964,7 +28421,7 @@ function assertion(run, a) {
27964
28421
  case "handlesFailure": {
27965
28422
  const u = findUseCase(run.diagram, a.useCase);
27966
28423
  if (!u) return unknownUseCase(run, a.useCase);
27967
- const ids = new Set(selectNodes(run.diagram, a.target).map((n) => n.id));
28424
+ const ids = idsOf(run, a.target);
27968
28425
  if (ids.size === 0) return noMatch(a.target);
27969
28426
  const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
27970
28427
  const what = selectorText(a.target);
@@ -27983,6 +28440,14 @@ function assertion(run, a) {
27983
28440
  if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
27984
28441
  return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
27985
28442
  }
28443
+ case "neverWaits":
28444
+ return neverWaits(run, a);
28445
+ case "after":
28446
+ return after(run, a);
28447
+ case "senderCalls":
28448
+ return senderCalls(run, a);
28449
+ case "startsAt":
28450
+ return startsAt(run, a);
27986
28451
  }
27987
28452
  }
27988
28453
  function noMatch(selector) {
@@ -27992,17 +28457,18 @@ function calls(run, a) {
27992
28457
  const sc = scope(run, a.useCase, a.scenario);
27993
28458
  if (isCheck(sc)) return sc;
27994
28459
  const what = selectorText(a.target);
27995
- const ids = new Set(selectNodes(run.diagram, a.target).map((n) => n.id));
27996
- const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId)).map((step) => step.toServiceId);
28460
+ const ids = idsOf(run, a.target);
28461
+ const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId));
27997
28462
  const calling = sc.scenarios.filter((s) => called(s).length > 0);
27998
28463
  const names = (list) => list.map((s) => quote(s.name)).join(", ");
27999
28464
  const multi = sc.scenarios.length > 1;
28000
28465
  if (a.quantifier === "never") {
28001
28466
  if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
28002
28467
  const first = calling[0];
28468
+ const step = called(first)[0];
28003
28469
  return fail(
28004
- `${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run.diagram, called(first)[0])}`,
28005
- `Take the call to ${called(first)[0]} out of ${quote(first.name)}, e.g. serve it from a cache`
28470
+ `${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run.diagram, step.toServiceId)}: ${stepText(step)}`,
28471
+ `Take the call to ${step.toServiceId} out of ${quote(first.name)}, e.g. serve it from a cache`
28006
28472
  );
28007
28473
  }
28008
28474
  if (ids.size === 0) return noMatch(a.target);
@@ -28017,22 +28483,22 @@ function calls(run, a) {
28017
28483
  function before(run, a) {
28018
28484
  const sc = scope(run, a.useCase, a.scenario);
28019
28485
  if (isCheck(sc)) return sc;
28020
- const first = new Set(selectNodes(run.diagram, a.first).map((n) => n.id));
28021
- const then = new Set(selectNodes(run.diagram, a.then).map((n) => n.id));
28486
+ const first = idsOf(run, a.first);
28487
+ const then = idsOf(run, a.then);
28022
28488
  const [x, y] = [selectorText(a.first), selectorText(a.then)];
28023
28489
  if (first.size === 0) return noMatch(a.first);
28024
28490
  if (then.size === 0) return noMatch(a.then);
28025
28491
  let checked = 0;
28026
28492
  for (const s of sc.scenarios) {
28027
- const order = requestOrder(sc.u, s).requests.map((m) => m.to);
28028
- const iy = order.findIndex((id) => then.has(id));
28493
+ const order = requestOrder(sc.u, s).requests;
28494
+ const iy = order.findIndex((m) => then.has(m.to));
28029
28495
  if (iy < 0) continue;
28030
28496
  checked++;
28031
- const ix = order.findIndex((id) => first.has(id));
28497
+ const ix = order.findIndex((m) => first.has(m.to));
28032
28498
  if (ix < 0 || ix > iy) {
28033
28499
  const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
28034
28500
  return fail(
28035
- ix < 0 ? `${where}, ${order[iy]} is called but ${x} never is` : `${where}, ${order[iy]} is called before ${order[ix]}`,
28501
+ ix < 0 ? `${where}, ${order[iy].to} is called (${stepText(order[iy].step)}) but ${x} never is` : `${where}, ${order[iy].to} is called (${stepText(order[iy].step)}) before ${order[ix].to} (${stepText(order[ix].step)})`,
28036
28502
  `Call ${x} before the first call to ${y}`
28037
28503
  );
28038
28504
  }
@@ -28040,6 +28506,100 @@ function before(run, a) {
28040
28506
  if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
28041
28507
  return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
28042
28508
  }
28509
+ function neverWaits(run, a) {
28510
+ const sc = scope(run, a.useCase, a.scenario);
28511
+ if (isCheck(sc)) return sc;
28512
+ const ids = idsOf(run, a.target);
28513
+ const what = selectorText(a.target);
28514
+ for (const s of sc.scenarios) {
28515
+ const hop = criticalPath(sc.u, s).flat().find((h) => !h.async && ids.has(h.target));
28516
+ if (hop) {
28517
+ const where = sc.scenarios.length > 1 ? quote(s.name) : sc.label;
28518
+ return fail(
28519
+ `${where} waits for ${nodeLabel(run.diagram, hop.target)} before responding: ${stepText(hop.step)}${hop.failed ? " times out on the request path" : " is synchronous"}`,
28520
+ `Send it with ->> (through a queue if it must not be lost), or move it after the line that answers the entry request`
28521
+ );
28522
+ }
28523
+ }
28524
+ const calledAtAll = sc.scenarios.some((s) => s.steps.some((step) => ids.has(step.toServiceId)));
28525
+ return pass(`${sc.label} never waits for ${what}${calledAtAll ? "" : ` (it never calls ${what})`}`);
28526
+ }
28527
+ function after(run, a) {
28528
+ const sc = scope(run, a.useCase, a.scenario);
28529
+ if (isCheck(sc)) return sc;
28530
+ const targets = idsOf(run, a.target);
28531
+ const firsts = idsOf(run, a.after);
28532
+ const [y, x] = [selectorText(a.target), selectorText(a.after)];
28533
+ if (targets.size === 0) return noMatch(a.target);
28534
+ if (firsts.size === 0) return noMatch(a.after);
28535
+ let checked = 0;
28536
+ for (const s of sc.scenarios) {
28537
+ const order = requestOrder(sc.u, s).requests;
28538
+ const ix = order.findIndex((m) => firsts.has(m.to));
28539
+ const iy = order.map((m) => targets.has(m.to)).lastIndexOf(true);
28540
+ if (ix < 0 || iy < 0) continue;
28541
+ checked++;
28542
+ if (iy < ix) {
28543
+ const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
28544
+ return fail(
28545
+ `${where}, the last call to ${y} (${stepText(order[iy].step)}) comes before the first call to ${x} (${stepText(order[ix].step)})`,
28546
+ `Call ${y} after ${x}: move the step to ${order[iy].to} below the call to ${order[ix].to}`
28547
+ );
28548
+ }
28549
+ }
28550
+ if (checked === 0) {
28551
+ const callsY = sc.scenarios.some((s) => s.steps.some((step) => targets.has(step.toServiceId)));
28552
+ const callsX = sc.scenarios.some((s) => s.steps.some((step) => firsts.has(step.toServiceId)));
28553
+ const why = !callsY && !callsX ? `calls neither ${x} nor ${y}` : !callsY ? `never calls ${y}` : !callsX ? `never calls ${x}` : `never calls ${x} and ${y} in the same scenario`;
28554
+ return fail(`${sc.label} ${why}`, `Add a step to ${y} after the call to ${x}`);
28555
+ }
28556
+ return pass(`${sc.label} calls ${y} after ${x}${checked > 1 ? ` in all ${checked} scenarios that call both` : ""}`);
28557
+ }
28558
+ function senderCalls(run, a) {
28559
+ let useCases = run.diagram.useCases;
28560
+ if (a.useCase !== void 0) {
28561
+ const u = findUseCase(run.diagram, a.useCase);
28562
+ if (!u) return unknownUseCase(run, a.useCase);
28563
+ useCases = [u];
28564
+ }
28565
+ const from = idsOf(run, a.from);
28566
+ const to = idsOf(run, a.to);
28567
+ const [x, y] = [selectorText(a.from), selectorText(a.to)];
28568
+ const within = a.useCase !== void 0 ? ` in ${quote(useCases[0].name)}` : "";
28569
+ for (const u of useCases) {
28570
+ for (const s of u.scenarios) {
28571
+ const step = s.steps.find((st) => from.has(st.fromServiceId) && to.has(st.toServiceId));
28572
+ if (!step) continue;
28573
+ const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
28574
+ if (a.quantifier === "some") return pass(`${x} calls ${y}${within}: ${stepText(step)} in ${where}`);
28575
+ return fail(
28576
+ `${x} calls ${y}${within}: ${stepText(step)} in ${where}`,
28577
+ `Let another node make this call (e.g. the client uploads to storage with a presigned URL), or remove the step`
28578
+ );
28579
+ }
28580
+ }
28581
+ if (a.quantifier === "never") return pass(`${x} never calls ${y}${within}`);
28582
+ if (from.size === 0) return noMatch(a.from);
28583
+ if (to.size === 0) return noMatch(a.to);
28584
+ return fail(`No step is sent by ${x} to ${y}${within}`, `Add a step ${[...from][0]} -> ${[...to][0]}${within}`);
28585
+ }
28586
+ function startsAt(run, a) {
28587
+ const u = findUseCase(run.diagram, a.useCase);
28588
+ if (!u) return unknownUseCase(run, a.useCase);
28589
+ const ids = idsOf(run, a.target);
28590
+ const what = selectorText(a.target);
28591
+ if (ids.size === 0) return noMatch(a.target);
28592
+ const entries = u.scenarios.map((s) => s.steps[0]).filter((s) => !!s);
28593
+ if (entries.length === 0) return fail(`${quote(u.name)} has no steps`, `Start "${u.name}" with a step sent by ${what}`);
28594
+ const wrong = entries.find((s) => !ids.has(s.fromServiceId));
28595
+ if (wrong) {
28596
+ return fail(
28597
+ `${quote(u.name)} starts at ${nodeLabel(run.diagram, wrong.fromServiceId)}, not ${what}: ${stepText(wrong)}`,
28598
+ `Make the first step of "${u.name}" a step sent by ${what}`
28599
+ );
28600
+ }
28601
+ return pass(`${quote(u.name)} starts at ${nodeLabel(run.diagram, entries[0].fromServiceId)}`);
28602
+ }
28043
28603
  function statusMatches(status, wanted) {
28044
28604
  if (status === void 0) return false;
28045
28605
  const cls = /^([1-5])xx$/i.exec(wanted);
@@ -28054,19 +28614,18 @@ function responds(run, a) {
28054
28614
  return fail(`${sc.label} never responds ${a.status}; it answers ${seen.join(", ") || "nothing"}`, `Add a scenario (alt) whose entry request is answered with ${a.status}`);
28055
28615
  }
28056
28616
  function noPath(run, from, to) {
28057
- const sources = new Set(selectNodes(run.diagram, from).map((n) => n.id));
28058
- const targets = new Set(selectNodes(run.diagram, to).map((n) => n.id));
28617
+ const sources = idsOf(run, from);
28618
+ const targets = idsOf(run, to);
28059
28619
  const [x, y] = [selectorText(from), selectorText(to)];
28060
- const line = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
28061
28620
  const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
28062
28621
  const edge = run.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
28063
- if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${line(edge.loc)}) goes directly from ${x} to ${y}`, hint);
28622
+ if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${lineOf(edge.loc)}) goes directly from ${x} to ${y}`, hint);
28064
28623
  for (const u of run.diagram.useCases) {
28065
28624
  for (const s of u.scenarios) {
28066
28625
  const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
28067
28626
  if (step) {
28068
28627
  const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
28069
- return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${line(step.loc)}) goes directly from ${x} to ${y}`, hint);
28628
+ return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${lineOf(step.loc)}) goes directly from ${x} to ${y}`, hint);
28070
28629
  }
28071
28630
  }
28072
28631
  }
@@ -28089,6 +28648,24 @@ var KEYWORDS2 = [
28089
28648
  { label: "decision", detail: "A design decision with its reasons", snippet: 'decision "${1:Title}" {\n because "${2:reason}"$0\n}' },
28090
28649
  { label: "test", detail: 'Flow assertions: "Use case" calls any cache', snippet: 'test "${1:Name}" {\n $0\n}' }
28091
28650
  ];
28651
+ var ASSERTIONS = [
28652
+ { label: '"Use case" calls', detail: "Some scenario calls the node", snippet: '"${1:Use case}" calls ${2:node}' },
28653
+ { label: '"Use case" never calls', detail: "No scenario calls the node", snippet: '"${1:Use case}" never calls ${2:node}' },
28654
+ { label: '"Use case" every scenario calls', detail: "Every scenario calls the node", snippet: '"${1:Use case}" every scenario calls ${2:node}' },
28655
+ { label: '"Use case" calls \u2026 before \u2026', detail: "The first call to one node comes before the first call to another", snippet: '"${1:Use case}" calls ${2:node} before ${3:node}' },
28656
+ { label: '"Use case" calls \u2026 after \u2026', detail: "The last call to one node comes after the first call to another", snippet: '"${1:Use case}" calls ${2:node} after ${3:node}' },
28657
+ { label: '"Use case" never waits for', detail: "No synchronous call to the node before the response", snippet: '"${1:Use case}" never waits for ${2:node}' },
28658
+ { label: '"Use case" writes \u2026 before responding', detail: "A write to the node happens before the response", snippet: '"${1:Use case}" writes ${2:node} before responding' },
28659
+ { label: '"Use case" responds', detail: "Status code or class of the entry response", snippet: '"${1:Use case}" responds ${2:2xx}' },
28660
+ { label: '"Use case" starts at', detail: "The entry request is sent by the node", snippet: '"${1:Use case}" starts at ${2:node}' },
28661
+ { label: '"Use case" has scenario', detail: "The use case has a scenario of this name", snippet: '"${1:Use case}" has scenario "${2:Scenario}"' },
28662
+ { label: '"Use case" handles failure of', detail: "A scenario fails the node and still answers", snippet: '"${1:Use case}" handles failure of ${2:node}' },
28663
+ { label: "node calls node", detail: "Some step is sent by one node to the other", snippet: "${1:node} calls ${2:node}" },
28664
+ { label: "node never calls node", detail: "No step is sent by one node to the other", snippet: "${1:node} never calls ${2:node}" },
28665
+ { label: 'in "Use case" node calls node', detail: "A step of the use case is sent by one node to the other", snippet: 'in "${1:Use case}" ${2:node} calls ${3:node}' },
28666
+ { label: "no path from \u2026 to \u2026", detail: "No chain of connections links the nodes", snippet: "no path from ${1:node} to ${2:node}" },
28667
+ { label: "node has replicas >=", detail: "Minimum replica count", snippet: "${1:node} has replicas >= ${2:2}" }
28668
+ ];
28092
28669
 
28093
28670
  // src/imports.ts
28094
28671
  var import_node_fs3 = require("node:fs");
@@ -28135,6 +28712,10 @@ function importLinks(result) {
28135
28712
  }
28136
28713
 
28137
28714
  // src/simulation.ts
28715
+ function formatGb(gb) {
28716
+ const n = (x, digits) => Number(x.toFixed(digits)).toLocaleString("en-US");
28717
+ return gb >= 1e3 ? `${n(gb / 1e3, 1)} TB` : `${n(gb, gb < 10 ? 1 : 0)} GB`;
28718
+ }
28138
28719
  function testDiagnostics(diagram) {
28139
28720
  const out = [];
28140
28721
  for (const r of runTests(diagram)) {
@@ -28153,9 +28734,12 @@ function nodeSimulation(diagram, nodeId) {
28153
28734
  const n = analyze(diagram).nodes.find((x) => x.id === nodeId);
28154
28735
  if (!n || n.kind === "client" || n.kind === "other") return void 0;
28155
28736
  const state = n.saturated ? " \u2014 **saturated**" : n.utilization > HOT ? " \u2014 hot" : "";
28737
+ const split2 = n.readLoadRps > 0 && n.writeLoadRps > 0 ? [`Reads ${formatRps(n.readLoadRps)} of ${formatRps(n.readCapacityRps)} (${formatPercent(n.readUtilization)}) \xB7 writes ${formatRps(n.writeLoadRps)} of ${formatRps(n.writeCapacityRps)} (${formatPercent(n.writeUtilization)})`] : [];
28738
+ const egress = n.egressGbPerMonth > 0 ? ` (${formatUsd(n.egressUsd).replace("/month", "")} of it egress, ${formatGb(n.egressGbPerMonth)})` : "";
28156
28739
  return [
28157
28740
  `Load ${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)} (${formatPercent(n.utilization)} utilised${state})`,
28158
- `Latency ${formatMs(n.latencyMs)} per call \xB7 availability ${formatAvailability(n.availability)} \xB7 ${formatUsd(n.costUsd)}${n.replicas > 1 ? ` for ${n.replicas} replicas` : ""}`
28741
+ ...split2,
28742
+ `Latency ${formatMs(n.latencyMs)} per call \xB7 availability ${formatAvailability(n.availability)} \xB7 ${formatUsd(n.costUsd)}${n.replicas > 1 ? ` for ${n.replicas} replicas` : ""}${egress}`
28159
28743
  ].join(" \n");
28160
28744
  }
28161
28745
 
@@ -28239,6 +28823,10 @@ function complete(analysis, pos) {
28239
28823
  range
28240
28824
  }));
28241
28825
  if (!atLineStart) return nodes;
28826
+ if (inTestBlock(analysis.lines, pos.line) && !/\}/.test(before2)) {
28827
+ const assertions = ASSERTIONS.map((a) => ({ label: a.label, kind: "keyword", detail: a.detail, snippet: a.snippet, range }));
28828
+ return [...assertions, ...nodes];
28829
+ }
28242
28830
  const afterBrace = /\}\s*$/.test(before2.slice(0, before2.length - word.length));
28243
28831
  const keywords = KEYWORDS2.filter((k) => !afterBrace || k.label === "alt").map((k) => ({
28244
28832
  label: k.label,
@@ -28249,6 +28837,14 @@ function complete(analysis, pos) {
28249
28837
  }));
28250
28838
  return afterBrace ? keywords : [...keywords, ...nodes];
28251
28839
  }
28840
+ function inTestBlock(lines, index) {
28841
+ for (let i = index - 1; i >= 0; i--) {
28842
+ const line = lines[i];
28843
+ if (/^\s*\}/.test(line)) return false;
28844
+ if (/\{\s*(?:#.*)?$/.test(line)) return /^\s*test\s+"/.test(line);
28845
+ }
28846
+ return false;
28847
+ }
28252
28848
  function describeNode(n) {
28253
28849
  return [n.name !== n.id ? n.name : "", n.techStack, n.ownerTeam ? `@${n.ownerTeam}` : ""].filter(Boolean).join(" \xB7 ");
28254
28850
  }
@@ -28361,12 +28957,12 @@ function quickFix(analysis, message) {
28361
28957
  if (updated === text) return null;
28362
28958
  return { title: `Add connection '${from} -> ${to}'`, edit: diffEdit(text, updated) };
28363
28959
  }
28364
- function diffEdit(before2, after) {
28960
+ function diffEdit(before2, after2) {
28365
28961
  let start = 0;
28366
- while (start < before2.length && start < after.length && before2[start] === after[start]) start++;
28962
+ while (start < before2.length && start < after2.length && before2[start] === after2[start]) start++;
28367
28963
  let end = 0;
28368
- while (end < before2.length - start && end < after.length - start && before2[before2.length - 1 - end] === after[after.length - 1 - end]) end++;
28369
- return { range: { start: positionAt(before2, start), end: positionAt(before2, before2.length - end) }, newText: after.slice(start, after.length - end) };
28964
+ while (end < before2.length - start && end < after2.length - start && before2[before2.length - 1 - end] === after2[after2.length - 1 - end]) end++;
28965
+ return { range: { start: positionAt(before2, start), end: positionAt(before2, before2.length - end) }, newText: after2.slice(start, after2.length - end) };
28370
28966
  }
28371
28967
  function positionAt(text, offset) {
28372
28968
  const before2 = text.slice(0, offset).split("\n");
@@ -28375,7 +28971,7 @@ function positionAt(text, offset) {
28375
28971
 
28376
28972
  // src/server.ts
28377
28973
  if (process.argv.includes("--version")) {
28378
- console.log(true ? "0.3.0" : "dev");
28974
+ console.log(true ? "0.5.0" : "dev");
28379
28975
  process.exit(0);
28380
28976
  }
28381
28977
  var connection = (0, import_node.createConnection)(import_node.ProposedFeatures.all);
@@ -28416,7 +29012,7 @@ connection.onInitialize(() => ({
28416
29012
  documentFormattingProvider: true,
28417
29013
  codeActionProvider: { codeActionKinds: [import_node.CodeActionKind.QuickFix] }
28418
29014
  },
28419
- serverInfo: { name: "proschi-language-server", version: true ? "0.3.0" : "dev" }
29015
+ serverInfo: { name: "proschi-language-server", version: true ? "0.5.0" : "dev" }
28420
29016
  }));
28421
29017
  function validate(document) {
28422
29018
  const analysis = analysisOf(document);