@substrat-run/kernel 0.120.0 → 0.121.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.
Files changed (39) hide show
  1. package/dist/index.d.ts +9 -5
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +7 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/list-index.d.ts.map +1 -1
  6. package/dist/list-index.js +5 -2
  7. package/dist/list-index.js.map +1 -1
  8. package/dist/module-migrations.d.ts +26 -0
  9. package/dist/module-migrations.d.ts.map +1 -0
  10. package/dist/module-migrations.js +21 -0
  11. package/dist/module-migrations.js.map +1 -0
  12. package/dist/platform-call.d.ts +20 -0
  13. package/dist/platform-call.d.ts.map +1 -1
  14. package/dist/platform-call.js +24 -0
  15. package/dist/platform-call.js.map +1 -1
  16. package/dist/platform-sweep.d.ts +157 -4
  17. package/dist/platform-sweep.d.ts.map +1 -1
  18. package/dist/platform-sweep.js +574 -45
  19. package/dist/platform-sweep.js.map +1 -1
  20. package/dist/scope-host.d.ts +105 -2
  21. package/dist/scope-host.d.ts.map +1 -1
  22. package/dist/scope-host.js.map +1 -1
  23. package/dist/sql-limits.d.ts +70 -0
  24. package/dist/sql-limits.d.ts.map +1 -0
  25. package/dist/sql-limits.js +197 -0
  26. package/dist/sql-limits.js.map +1 -0
  27. package/dist/system-switch-record.d.ts +158 -0
  28. package/dist/system-switch-record.d.ts.map +1 -0
  29. package/dist/system-switch-record.js +227 -0
  30. package/dist/system-switch-record.js.map +1 -0
  31. package/dist/ulid.d.ts +6 -0
  32. package/dist/ulid.d.ts.map +1 -1
  33. package/dist/ulid.js +21 -7
  34. package/dist/ulid.js.map +1 -1
  35. package/dist/vertical-events.d.ts +96 -2
  36. package/dist/vertical-events.d.ts.map +1 -1
  37. package/dist/vertical-events.js +230 -1
  38. package/dist/vertical-events.js.map +1 -1
  39. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { drainedEvent, instant } from '@substrat-run/contracts';
1
+ import { drainedEvent, errorCodeOf, instant, substratError } from '@substrat-run/contracts';
2
2
  import { backoffAt } from './scope-host.js';
3
3
  import { MIGRATION_FLAG_THRESHOLD, migrationFleet, migrationProgress, scopeMigrationState } from './migration-progress.js';
4
4
  import { UNDRAINED_SKIPPED_IDS } from './outbox-event.js';
@@ -134,6 +134,21 @@ export function runningVersionOf(scope, serving) {
134
134
  return serving.versionId;
135
135
  return scope.verticalVersionId;
136
136
  }
137
+ /**
138
+ * Every vertical's serving pointer, for `runningVersionOf` — one `listVerticals` read, and only
139
+ * when some scope is actually on a serving script, since nothing else can differ from its
140
+ * binding. Shared by the provision reconcile (#1653) and the cross-vertical narrowing (#1705).
141
+ */
142
+ async function servingPointersFor(admin, actor, scopes) {
143
+ const serving = new Map();
144
+ if (!scopes.some((s) => s.servingRef))
145
+ return serving;
146
+ for (const v of await admin.listVerticals(actor)) {
147
+ if (v.servingRef && v.servingVersionId)
148
+ serving.set(v.slug, { ref: v.servingRef, versionId: v.servingVersionId });
149
+ }
150
+ return serving;
151
+ }
137
152
  /**
138
153
  * This pass's share of the behind scopes: at most `batch`, as a contiguous window over
139
154
  * the id order, starting at `rng()` of the way round and wrapping. Deterministic for a
@@ -374,16 +389,7 @@ export async function runPlatformSweep(host, options) {
374
389
  deferred: 0,
375
390
  };
376
391
  const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
377
- // One directory read for every vertical's serving pointer — and only when some scope
378
- // is actually on a serving script, since nothing else can differ from its binding.
379
- const serving = new Map();
380
- if (scopes.some((s) => s.servingRef)) {
381
- for (const v of await host.admin.listVerticals(options.actor)) {
382
- if (v.servingRef && v.servingVersionId) {
383
- serving.set(v.slug, { ref: v.servingRef, versionId: v.servingVersionId });
384
- }
385
- }
386
- }
392
+ const serving = await servingPointersFor(host.admin, options.actor, scopes);
387
393
  const behind = [];
388
394
  for (const s of scopes) {
389
395
  const running = runningVersionOf(s, s.vertical ? serving.get(s.vertical) : null);
@@ -736,7 +742,9 @@ export async function runPlatformSweep(host, options) {
736
742
  * One pass over every cross-vertical edge (#1705). Bounded like the event drain: one batch per
737
743
  * edge per pass, reported rather than looped, so one busy producer cannot starve the rest.
738
744
  */
739
- async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
745
+ async function sweepCrossVertical(host, options, cv, failedThisPass, report,
746
+ /** Run only this producer's outgoing edges (the router kick). Absent: every edge. */
747
+ only) {
740
748
  const out = {
741
749
  edges: [],
742
750
  delivered: 0,
@@ -763,7 +771,21 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
763
771
  // fork is a copy of somebody's data. Delivering into it would feed a copy as though it were
764
772
  // the install, and reading from it would publish a copy's history to another vertical. A
765
773
  // preview is not the install either.
766
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
774
+ //
775
+ // A producer-scoped run reads only that tenant's scopes: both ends of an edge are in one
776
+ // tenant, so nothing outside it can be a consumer of this producer or change how it resolves.
777
+ const scopes = (await host.admin.listScopes(options.actor, { status: 'active', ...(only ? { tenantId: only.tenantId } : {}) })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
778
+ // The kick names a scope, and the edges run are the ones whose producer RESOLVES to it. A
779
+ // fork, a preview, a second install or a scope of another tenant is not a producer, so a
780
+ // kick naming one runs nothing (the sweep would resolve the same way).
781
+ let from = null;
782
+ if (only) {
783
+ const named = scopes.find((s) => s.id === only.scopeId && s.tenantId === only.tenantId);
784
+ const resolved = named ? resolveVerticalInstanceFrom(scopes, named.tenantId, named.vertical) : null;
785
+ if (!named || resolved?.outcome !== 'resolved' || resolved.instance.scopeId !== named.id)
786
+ return out;
787
+ from = named.vertical;
788
+ }
767
789
  const record = (edge) => {
768
790
  out.edges.push(edge);
769
791
  out.delivered += edge.delivered;
@@ -782,6 +804,12 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
782
804
  // Idle writes no row: one green row per edge per tick would bury the ones that matter.
783
805
  if (edge.state === 'idle')
784
806
  return;
807
+ // A kick pass (`only`) can run every few seconds for a busy producer. It writes a row only
808
+ // for an edge that moved or paused. A standing failure (an old consumer, an unresolved
809
+ // producer) is the scheduled sweep's to record, once per tick, rather than once per kick:
810
+ // otherwise one broken consumer beside a busy producer files thousands of identical rows.
811
+ if (only && edge.state !== 'delivered' && edge.state !== 'paused')
812
+ return;
785
813
  options.recordSweepRun?.({
786
814
  kind: 'vertical-events',
787
815
  unit: `${edge.consumer.scopeId}:${edge.producer.vertical}`,
@@ -799,7 +827,47 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
799
827
  };
800
828
  // Narrowed BEFORE any scope is called, then capped. The resolution below still needs every
801
829
  // primary scope (a producer is any of them), and that is the one directory read above.
802
- const candidates = await candidatesOf(scopes);
830
+ // Contained: the narrowing may read the version registry (the control plane's reach), and a
831
+ // failed read must cost this phase one pass, not sink the phases after it. No scope is called,
832
+ // every watermark holds, and the next pass asks again.
833
+ // Scopes kept only because the narrowing could not judge them: their "imports nothing" is an answer.
834
+ const doubtful = new Set();
835
+ // Scopes the narrowing KNOWS import: their "imports nothing" contradicts the registry.
836
+ const knownImporters = new Set();
837
+ let candidates;
838
+ try {
839
+ candidates = await candidatesOf(scopes, {
840
+ ...(from !== null ? { from } : {}),
841
+ known: (scopeIds) => {
842
+ for (const id of scopeIds)
843
+ knownImporters.add(id);
844
+ },
845
+ doubt: (unit, reason, scopeIds) => {
846
+ for (const id of scopeIds)
847
+ doubtful.add(id);
848
+ // A kick pass leaves doubt to the sweep: see the filter below and `record`.
849
+ if (only)
850
+ return;
851
+ options.recordSweepRun?.({
852
+ kind: 'vertical-events',
853
+ unit: `version:${unit}`,
854
+ outcome: 'failed',
855
+ operation: 'sweep.vertical-events:narrowing',
856
+ error: `the version registry cannot say whether ${unit} imports anything (${reason}); ` +
857
+ `its scopes are asked directly this pass`,
858
+ });
859
+ },
860
+ });
861
+ }
862
+ catch (err) {
863
+ report.errors.push({ kind: 'vertical-events', id: 'candidates', error: message(err) });
864
+ return out;
865
+ }
866
+ // A kick pass calls only the consumers the registry KNOWS import from this producer. A scope
867
+ // kept as doubt is asked by the scheduled sweep, once per tick. Asking it on every kick would
868
+ // cost a call, and a row, per flagged response.
869
+ if (only)
870
+ candidates = candidates.filter((c) => !doubtful.has(c.id));
803
871
  out.candidates = candidates.length;
804
872
  const configuredCap = cv.maxConsumers ?? CROSS_VERTICAL_CONSUMERS_PER_PASS;
805
873
  const cap = Number.isFinite(configuredCap) && configuredCap >= 0 ? Math.floor(configuredCap) : CROSS_VERTICAL_CONSUMERS_PER_PASS;
@@ -808,20 +876,38 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
808
876
  await mapBounded(visiting, options.concurrency ?? 8, async (consumer) => {
809
877
  if (failedThisPass.has(consumer.id))
810
878
  return;
879
+ // The consumer side, failing before any producer is named: an edge to `*`, so it lands in
880
+ // the sweep-run rows under `<scope>:*` beside the per-producer edges, not only in `errors`.
881
+ const consumerFailed = (reason) => record({
882
+ tenantId: consumer.tenantId,
883
+ consumer: { scopeId: consumer.id, vertical: consumer.vertical ?? '' },
884
+ producer: { vertical: '*', scopeId: null },
885
+ state: 'failed',
886
+ delivered: 0,
887
+ deadLettered: 0,
888
+ withheld: 0,
889
+ duplicates: 0,
890
+ reason,
891
+ });
811
892
  let state;
812
893
  try {
813
894
  state = await reach.importState(consumer.tenantId, consumer.id);
814
895
  }
815
896
  catch (err) {
816
- report.errors.push({ kind: 'vertical-events', id: consumer.id, error: message(err) });
897
+ consumerFailed(`could not read the consumer's imports: ${message(err)}`);
817
898
  return;
818
899
  }
819
- const bySource = new Map();
820
- for (const c of state.consumes) {
821
- const list = bySource.get(c.from) ?? [];
822
- list.push({ type: c.type, schemaVersion: c.schemaVersion });
823
- bySource.set(c.from, list);
900
+ // A scope the narrowing KNOWS imports (`hint.known`) whose deployment answers that it imports
901
+ // NOTHING: the two disagree, and the scope is not running the code the registry describes (a
902
+ // push that did not reach it, or a reconcile still owed). Said, not skipped: skipping would
903
+ // make every edge into this scope disappear without a trace. Any other candidate (a superset
904
+ // narrowing, which the contract allows, or a doubtful scope) answering "nothing" has answered.
905
+ if (state.consumes.length === 0 && knownImporters.has(consumer.id)) {
906
+ consumerFailed("the version registry says this scope's code imports events, but its deployment answers that it " +
907
+ 'imports nothing — it is not running the version the registry names; redeploy or reconcile it');
908
+ return;
824
909
  }
910
+ const bySource = wantsBySource(state.consumes, (f) => from === null || f === from);
825
911
  // Sequential per consumer: its edges share the consumer's serialization queue anyway,
826
912
  // and one consumer with many sources must not hold more than one slot of the pass.
827
913
  for (const [from, wants] of [...bySource.entries()].sort(([a], [b]) => a.localeCompare(b))) {
@@ -830,46 +916,259 @@ async function sweepCrossVertical(host, options, cv, failedThisPass, report) {
830
916
  });
831
917
  return out;
832
918
  }
833
- async function sweepEdge(reach, scopes, consumer, from, wants, state, budget) {
834
- const vertical = consumer.vertical;
835
- const edge = (rest) => ({
836
- tenantId: consumer.tenantId,
837
- consumer: { scopeId: consumer.id, vertical },
838
- producer: { vertical: from, scopeId: null },
839
- delivered: 0,
840
- deadLettered: 0,
841
- withheld: 0,
842
- duplicates: 0,
843
- ...rest,
919
+ /**
920
+ * Run ONE producer's outgoing edges now (#1705 PR 2): the half of the router kick that makes a
921
+ * cross-vertical event arrive in seconds rather than at the next sweep.
922
+ *
923
+ * The same phase, under the same rules, narrowed. It reads the producer's tenant only, runs only
924
+ * edges whose producer RESOLVES to `producer` (so a fork, a preview or a second install runs
925
+ * nothing), asks `candidates` with `{ from }` so a consumer that imports nothing from this
926
+ * vertical is never called, and keeps the per-pass consumer cap. What it moves is exactly what
927
+ * the next sweep would have moved: the watermark's compare-and-set makes the two safe to overlap,
928
+ * and an edge a kick has taken reads as `idle` to the sweep that follows.
929
+ */
930
+ export async function runCrossVerticalFrom(host, options, producer) {
931
+ const report = { errors: [] };
932
+ const crossVertical = await sweepCrossVertical(host, options, options.crossVertical, new Set(), report, producer);
933
+ return { crossVertical, errors: report.errors };
934
+ }
935
+ /**
936
+ * The control plane's `CrossVerticalReach.candidates` (#1705 PR 2): narrow the listed scopes to
937
+ * those whose RUNNING version may import, read from the version registry and never from a scope.
938
+ *
939
+ * Per pass: one `listVerticals` (the serving pointers, only when some scope is on a serving
940
+ * script), and one `readImports` per DISTINCT running version, at most `concurrency` in flight.
941
+ * The control plane's reader is an unaudited directory read behind a cache keyed (slug,
942
+ * version), since a pushed version's manifest never changes. So after the first pass it costs
943
+ * no read at all.
944
+ *
945
+ * Only a version that says it imports nothing is dropped (`none`: no manifest, no registry, no
946
+ * `imports` key). A version the registry cannot answer for (`unreadable`: a manifest that does
947
+ * not parse, a malformed row, a version the registry does not know, a failed read) keeps its
948
+ * scopes as candidates, and the scope's own `importState` decides. Excluding a consumer wrongly
949
+ * loses its edge with no trace. Including one wrongly costs one call. Each such version is
950
+ * reported once per pass through `hint.doubt`, and a failure on one version never stops the
951
+ * others.
952
+ */
953
+ /**
954
+ * What each scope's RUNNING version imports, from the registry (#1705): one `listVerticals` when
955
+ * a scope is on a serving script, then one `readImports` per distinct running version, at most
956
+ * `concurrency` in flight. A failure on one version never stops the others: a read that throws is
957
+ * reported as `threw` beside the fact, since a caller may treat "the registry could not be asked"
958
+ * differently from "the manifest does not parse". A scope bound to no version gets no fact. The narrowing and the promote gate
959
+ * both ask it, so the two cannot come to disagree about which code a scope runs.
960
+ */
961
+ async function runningImportsOf(admin, actor, scopes, readImports, concurrency) {
962
+ const serving = await servingPointersFor(admin, actor, scopes);
963
+ const versions = new Map();
964
+ const keyed = [];
965
+ for (const s of scopes) {
966
+ if (!s.vertical)
967
+ continue;
968
+ const versionId = runningVersionOf(s, serving.get(s.vertical));
969
+ const key = `${s.vertical}@${versionId ?? '(no version)'}`;
970
+ if (versionId && !versions.has(key))
971
+ versions.set(key, { slug: s.vertical, versionId });
972
+ keyed.push({ scope: s, key, versionId });
973
+ }
974
+ const facts = new Map();
975
+ const threw = new Map();
976
+ await mapBounded([...versions], concurrency, async ([key, v]) => {
977
+ try {
978
+ facts.set(key, await readImports(v.slug, v.versionId));
979
+ }
980
+ catch (err) {
981
+ facts.set(key, { kind: 'unreadable', reason: message(err) });
982
+ threw.set(key, message(err));
983
+ }
844
984
  });
845
- if (from === vertical) {
846
- return edge({ state: 'unresolved', reason: `'${vertical}' imports from itself — an import names ANOTHER vertical` });
985
+ return keyed.map((k) => ({ ...k, fact: facts.get(k.key), ...(threw.has(k.key) ? { threw: threw.get(k.key) } : {}) }));
986
+ }
987
+ export function registryImportCandidates(input) {
988
+ return async (scopes, hint) => {
989
+ const running = await runningImportsOf(input.admin, input.actor, scopes, input.readImports, input.concurrency ?? 8);
990
+ const doubted = new Map();
991
+ const known = [];
992
+ const out = [];
993
+ for (const { scope, key, fact: read } of running) {
994
+ const fact = read ?? {
995
+ kind: 'unreadable',
996
+ reason: 'the scope names no version the registry could be asked about',
997
+ };
998
+ if (fact.kind === 'none')
999
+ continue;
1000
+ if (fact.kind === 'imports') {
1001
+ const from = hint?.from;
1002
+ if (from !== undefined ? fact.rows.some((r) => r.from === from) : fact.rows.length > 0) {
1003
+ out.push(scope);
1004
+ known.push(scope.id);
1005
+ }
1006
+ continue;
1007
+ }
1008
+ out.push(scope);
1009
+ const d = doubted.get(key) ?? { reason: fact.reason, scopeIds: [] };
1010
+ d.scopeIds.push(scope.id);
1011
+ doubted.set(key, d);
1012
+ }
1013
+ for (const [key, d] of doubted)
1014
+ hint?.doubt?.(key, d.reason, d.scopeIds);
1015
+ if (known.length > 0)
1016
+ hint?.known?.(known);
1017
+ return out;
1018
+ };
1019
+ }
1020
+ /**
1021
+ * Who a promote breaks (#1705 PR 3): the installed consumers whose RUNNING code imports an
1022
+ * exported (type, schemaVersion) that the outgoing version promised and the incoming one drops
1023
+ * or exports at another version. The promote gate refuses on a non-empty answer unless it is
1024
+ * acknowledged (`exportBreak`), and the refusal lists them.
1025
+ *
1026
+ * - **What changed** is outgoing versus incoming, from the two versions' registries. An outgoing
1027
+ * version whose registry states no exports (a first promote, or a push by a CLI older than
1028
+ * 0.34.0) promised nothing the registry can name, so nothing is judged broken. Nothing it
1029
+ * exported at run time could be listed either, which is what the permission-digest gate
1030
+ * beside this one is for.
1031
+ * - **Who is a consumer** is the edge's own rule: a primary, active scope of ANOTHER vertical, in
1032
+ * a tenant where the producer is installed (any primary, active install, so two installs still
1033
+ * count), whose running version (`runningVersionOf`) declares the import. That over-approximates
1034
+ * on purpose, for a listed vertical's tenants still pinned to an older producer version: a
1035
+ * refusal that names one tenant too many costs an acknowledgement, and one that names too few
1036
+ * costs a silent stall.
1037
+ * - **A consumer version whose manifest does not parse** is not judged. It cannot be said to
1038
+ * break, and refusing every promote over a manifest nobody can parse would make the gate one
1039
+ * nobody can pass. A read that THREW is different: the registry could not be asked, so whom the
1040
+ * promote breaks is unknown, and this throws. The gate then refuses rather than failing open.
1041
+ * A failed directory read throws the same way.
1042
+ *
1043
+ * Cost: nothing at all unless an export changed. Then one fleet `listScopes`, one
1044
+ * `listVerticals` when a scope is on a serving script, and one `readImports` per distinct
1045
+ * running consumer version, at most `concurrency` in flight.
1046
+ */
1047
+ export async function exportBreaksOf(input) {
1048
+ // An outgoing manifest that does not parse promised SOMETHING nobody can read. Judging it as
1049
+ // "promised nothing" would pass every break unacknowledged, so it refuses, as a thrown read does.
1050
+ if (input.outgoing.kind === 'unreadable') {
1051
+ throw substratError('unavailable', `cannot say whom this promotion breaks: the outgoing version's exports could not be read (${input.outgoing.reason})`);
1052
+ }
1053
+ if (input.outgoing.kind !== 'exports')
1054
+ return [];
1055
+ const incoming = new Map(input.incoming.kind === 'exports' ? input.incoming.rows.map((r) => [r.type, r.schemaVersion]) : []);
1056
+ const changed = new Map();
1057
+ for (const r of input.outgoing.rows) {
1058
+ const now = incoming.get(r.type) ?? null;
1059
+ if (now !== r.schemaVersion)
1060
+ changed.set(r.type, { schemaVersion: r.schemaVersion, incoming: now });
1061
+ }
1062
+ if (changed.size === 0)
1063
+ return [];
1064
+ const scopes = (await input.admin.listScopes(input.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
1065
+ const installedIn = new Set(scopes.filter((s) => s.vertical === input.producer).map((s) => s.tenantId));
1066
+ const consumers = scopes.filter((s) => s.vertical !== input.producer && installedIn.has(s.tenantId));
1067
+ if (consumers.length === 0)
1068
+ return [];
1069
+ const out = [];
1070
+ for (const { scope, key, versionId, fact, threw } of await runningImportsOf(input.admin, input.actor, consumers, input.readImports, input.concurrency ?? 8)) {
1071
+ if (threw !== undefined) {
1072
+ throw substratError('unavailable', `cannot say whom this promotion breaks: the registry could not be asked what ${key} imports (${threw}) — retry`);
1073
+ }
1074
+ if (fact?.kind !== 'imports')
1075
+ continue;
1076
+ for (const row of fact.rows) {
1077
+ const hit = row.from === input.producer ? changed.get(row.type) : undefined;
1078
+ if (!hit || hit.schemaVersion !== row.schemaVersion)
1079
+ continue;
1080
+ out.push({
1081
+ tenantId: scope.tenantId,
1082
+ scopeId: scope.id,
1083
+ vertical: scope.vertical,
1084
+ version: versionId,
1085
+ type: row.type,
1086
+ schemaVersion: row.schemaVersion,
1087
+ incoming: hit.incoming,
1088
+ });
1089
+ }
847
1090
  }
848
- // Both ends must be the tenant's one primary instance of their vertical, by #1706's one rule
849
- // (`resolveVerticalInstanceFrom`: same tenant, active, primary; two are `ambiguous`, refused
850
- // rather than guessed). The producer is who is read. The consumer is who the producer's grant
851
- // names, and a grant naming a slug cannot tell two installs of it apart.
1091
+ return out.sort((a, b) => a.tenantId.localeCompare(b.tenantId) || a.scopeId.localeCompare(b.scopeId) || a.type.localeCompare(b.type));
1092
+ }
1093
+ /** How `exportBreakRefusal` begins: what lets the promote route recognise it and add the listing. */
1094
+ export const EXPORT_BREAK_REFUSAL = 'promotion drops or re-versions';
1095
+ /** Whether a throw is the promote gate's export-break refusal (`exportBreakRefusal`). */
1096
+ export function isExportBreakRefusal(err) {
1097
+ return errorCodeOf(err) === 'precondition_failed' && err instanceof Error && err.message.startsWith(EXPORT_BREAK_REFUSAL);
1098
+ }
1099
+ /**
1100
+ * The refusal a promote gets for a non-empty `exportBreaksOf` (#1705 PR 3). Counts only: the
1101
+ * adapters do not know who is promoting, and a builder must not learn from an error which other
1102
+ * tenants import its events. The route that knows the caller lists what they may see.
1103
+ */
1104
+ export function exportBreakRefusal(breaks) {
1105
+ const types = new Set(breaks.map((b) => b.type)).size;
1106
+ const apps = new Set(breaks.map((b) => b.scopeId)).size;
1107
+ const tenants = new Set(breaks.map((b) => b.tenantId)).size;
1108
+ return (`${EXPORT_BREAK_REFUSAL} ${types} exported event type(s) that ${apps} installed app(s) in ` +
1109
+ `${tenants} tenant(s) import — their edges would stop delivering it. Acknowledge it explicitly ` +
1110
+ `(exportBreak) to promote, or keep the export and bump its consumers first`);
1111
+ }
1112
+ /**
1113
+ * The two ends of the edge from `from` into `consumer`, by #1706's one rule
1114
+ * (`resolveVerticalInstanceFrom`: same tenant, active, primary; two are `ambiguous`, refused rather
1115
+ * than guessed). The producer is who is read. The consumer is who the producer's grant names, and a
1116
+ * grant naming a slug cannot tell two installs of it apart. Shared by the sweep and the health read,
1117
+ * so the view cannot disagree with the next pass about where an edge points, or why it points
1118
+ * nowhere.
1119
+ */
1120
+ function edgeEnds(scopes, consumer, from) {
1121
+ const vertical = consumer.vertical;
1122
+ if (from === vertical)
1123
+ return { reason: `'${vertical}' imports from itself — an import names ANOTHER vertical` };
852
1124
  const self = resolveVerticalInstanceFrom(scopes, consumer.tenantId, vertical);
853
1125
  if (self.outcome !== 'resolved' || self.instance.scopeId !== consumer.id) {
854
- return edge({
855
- state: 'unresolved',
1126
+ return {
856
1127
  reason: `this tenant has more than one primary instance of '${vertical}', so the producer cannot tell which one its grant names`,
857
- });
1128
+ };
858
1129
  }
859
1130
  const producer = resolveVerticalInstanceFrom(scopes, consumer.tenantId, from);
860
1131
  if (producer.outcome !== 'resolved') {
861
- return edge({
862
- state: 'unresolved',
1132
+ return {
863
1133
  reason: producer.outcome === 'not-installed'
864
1134
  ? `'${from}' is not installed in this tenant`
865
1135
  : `this tenant has more than one primary instance of '${from}' — delivery waits until one is chosen`,
866
- });
1136
+ };
1137
+ }
1138
+ return { source: { vertical: from, scopeId: producer.instance.scopeId } };
1139
+ }
1140
+ /** A consumer's declared imports, grouped by the vertical they come from. */
1141
+ function wantsBySource(consumes, only = () => true) {
1142
+ const bySource = new Map();
1143
+ for (const c of consumes) {
1144
+ if (!only(c.from))
1145
+ continue;
1146
+ const list = bySource.get(c.from) ?? [];
1147
+ list.push({ type: c.type, schemaVersion: c.schemaVersion });
1148
+ bySource.set(c.from, list);
867
1149
  }
868
- const source = { vertical: from, scopeId: producer.instance.scopeId };
1150
+ return bySource;
1151
+ }
1152
+ async function sweepEdge(reach, scopes, consumer, from, wants, state, budget) {
1153
+ const vertical = consumer.vertical;
1154
+ const edge = (rest) => ({
1155
+ tenantId: consumer.tenantId,
1156
+ consumer: { scopeId: consumer.id, vertical },
1157
+ producer: { vertical: from, scopeId: null },
1158
+ delivered: 0,
1159
+ deadLettered: 0,
1160
+ withheld: 0,
1161
+ duplicates: 0,
1162
+ ...rest,
1163
+ });
1164
+ const ends = edgeEnds(scopes, consumer, from);
1165
+ if ('reason' in ends)
1166
+ return edge({ state: 'unresolved', reason: ends.reason });
1167
+ const { source } = ends;
869
1168
  const at = { producer: source };
870
1169
  const after = state.cursors.find((c) => c.source === source.scopeId)?.cursor ?? null;
871
1170
  try {
872
- const batch = await reach.readExports(producer.instance.tenantId, source.scopeId, {
1171
+ const batch = await reach.readExports(consumer.tenantId, source.scopeId, {
873
1172
  consumer: vertical,
874
1173
  after,
875
1174
  wants,
@@ -921,6 +1220,236 @@ async function sweepEdge(reach, scopes, consumer, from, wants, state, budget) {
921
1220
  return edge({ ...at, state: 'failed', reason: message(err) });
922
1221
  }
923
1222
  }
1223
+ /**
1224
+ * Where every cross-vertical edge of one tenant stands, read LIVE (#1705 PR 3): the view behind
1225
+ * the console's and the dashboard's edge health.
1226
+ *
1227
+ * It is the sweep's own walk with the delivery taken out. It uses the same reach, the same
1228
+ * narrowing (`candidates`, which keeps it off scopes that import nothing), and the same resolution
1229
+ * of both ends (`edgeEnds`). So it cannot disagree with the next pass about which edges exist or
1230
+ * where they point. Per edge it makes one probe read of the producer after the watermark,
1231
+ * `limit: 1`, which tells behind from caught up and dates the oldest waiting event. That read is the
1232
+ * producer's own `readExportedEvents`, gated and audited as on a pass, and nothing it returns is
1233
+ * delivered or kept. The consumer's door is read from its peer switch (`peerGrantsStatus`), because
1234
+ * a consumer refusing the producer is visible only at delivery otherwise. The tenant's recent
1235
+ * `vertical-events` sweep-run rows add the last pass that delivered and the last that did not.
1236
+ *
1237
+ * `focus` narrows it to one scope's edges, the ones into it and out of it: a per-app view asks
1238
+ * nothing about the rest of the tenant. Out of it means consumers the narrowing names for the
1239
+ * focus's vertical (`hint.from`), so the registry, not a scope, decides who is asked.
1240
+ *
1241
+ * Cost per view: one directory read, the narrowing, then per importing scope one `importState`
1242
+ * and one door read, per edge one probe read and one or two sweep-run reads. It is bounded by the
1243
+ * tenant, never the fleet.
1244
+ *
1245
+ * Who the reads are audited as: the directory, door and history reads as `actor`, the viewer the
1246
+ * caller names. The reach's reads (`importState`, the probe) are audited as whatever actor the
1247
+ * reach was built with. On the control plane that is the sweep's own actor, since it is the
1248
+ * sweep's reach, so those rows read as the platform's, not the viewer's.
1249
+ *
1250
+ * `unavailable` is its own state: a side that could not be asked gets no health, and no
1251
+ * surface may show it as healthy.
1252
+ */
1253
+ export async function crossVerticalHealth(host, options) {
1254
+ const { actor, tenantId } = options;
1255
+ const now = options.now ?? Date.now;
1256
+ const concurrency = options.concurrency ?? 8;
1257
+ const report = {
1258
+ tenantId,
1259
+ checkedAt: new Date(now()).toISOString(),
1260
+ edges: [],
1261
+ unavailable: null,
1262
+ history: { available: true, reason: null },
1263
+ };
1264
+ const cv = options.crossVertical ?? {};
1265
+ // No reach, on a host whose apps live in their own deployments (the shared control plane without
1266
+ // DISPATCH): this host's own verbs cannot see a single hosted scope, and its registered modules
1267
+ // import nothing, so the default would report "no edges". That is an answer it cannot give.
1268
+ if (!cv.reach && host.servesScopesElsewhere?.()) {
1269
+ report.unavailable =
1270
+ "edge health cannot reach the deployments that serve this tenant's apps: no cross-vertical reach is " +
1271
+ 'configured on this control plane (DISPATCH / PLATFORM_SECRET)';
1272
+ return report;
1273
+ }
1274
+ const reach = cv.reach ?? {
1275
+ importState: (t, s) => host.admin.importState(actor, t, s),
1276
+ readExports: (t, s, input) => host.admin.readExportedEvents(actor, t, s, input),
1277
+ deliver: () => Promise.reject(new Error('edge health never delivers')),
1278
+ };
1279
+ const candidatesOf = reach.candidates?.bind(reach) ??
1280
+ ((scopes) => ((host.registeredImports?.() ?? []).length > 0 ? scopes : []));
1281
+ const doorOf = options.door ?? ((t, s) => host.admin.peerGrantsStatus(actor, { tenantId: t, scopeId: s }));
1282
+ const knownImporters = new Set();
1283
+ const known = (ids) => {
1284
+ for (const id of ids)
1285
+ knownImporters.add(id);
1286
+ };
1287
+ const listed = await (async () => {
1288
+ const scopes = (await host.admin.listScopes(actor, { status: 'active', tenantId })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
1289
+ if (options.focus === undefined)
1290
+ return { scopes, candidates: await candidatesOf(scopes, { known }) };
1291
+ const focus = scopes.find((s) => s.id === options.focus);
1292
+ if (!focus)
1293
+ return { scopes, candidates: [] };
1294
+ const [into, outOf] = await Promise.all([
1295
+ candidatesOf([focus], { known }),
1296
+ candidatesOf(scopes, { from: focus.vertical, known }),
1297
+ ]);
1298
+ return { scopes, focus, candidates: [...into, ...outOf.filter((c) => c.id !== focus.id)] };
1299
+ })().then((v) => ({ ok: true, ...v }), (err) => ({ ok: false, error: message(err) }));
1300
+ if (!listed.ok) {
1301
+ report.unavailable = `could not list this tenant's apps that import events: ${listed.error}`;
1302
+ return report;
1303
+ }
1304
+ const { scopes, candidates } = listed;
1305
+ const focusScope = 'focus' in listed ? listed.focus : undefined;
1306
+ const focusVertical = focusScope?.vertical;
1307
+ // Which sweep-run unit each edge's history is filed under: `<consumer>:<producer vertical>`,
1308
+ // or `<consumer>:*` for a consumer that could not be asked (whatever the view tags it with).
1309
+ const unitOf = new Map();
1310
+ await mapBounded(candidates, concurrency, async (consumer) => {
1311
+ const vertical = consumer.vertical ?? '';
1312
+ const edge = (from, rest) => ({
1313
+ tenantId,
1314
+ consumer: { scopeId: consumer.id, vertical },
1315
+ producer: { vertical: from, scopeId: null },
1316
+ reason: null,
1317
+ watermark: null,
1318
+ oldestPending: null,
1319
+ lagMs: null,
1320
+ unexported: [],
1321
+ lastDelivered: null,
1322
+ lastProblem: null,
1323
+ ...rest,
1324
+ });
1325
+ const filed = (e, unitFrom) => {
1326
+ unitOf.set(e, `${consumer.id}:${unitFrom}`);
1327
+ return e;
1328
+ };
1329
+ // Into the focus, every source. Into anyone else, only the focus's own vertical.
1330
+ const wanted = (from) => focusVertical === undefined || consumer.id === options.focus || from === focusVertical;
1331
+ // The consumer's imports and its door, together: the door read is wasted only on a consumer
1332
+ // that turns out to import nothing, which the narrowing has already made rare.
1333
+ const [stateRead, doorRead] = await Promise.all([
1334
+ reach.importState(tenantId, consumer.id).then((state) => ({ ok: true, state }), (err) => ({ ok: false, error: message(err) })),
1335
+ doorOf(tenantId, consumer.id).then((entries) => ({ ok: true, entries }), (err) => ({ ok: false, error: message(err) })),
1336
+ ]);
1337
+ // A consumer that could not be asked names no producer ('*'). Seen from a focused PRODUCER,
1338
+ // though, it is one of that producer's consumers (the narrowing named it for the focus's
1339
+ // vertical), so it is tagged with the focus: a view of the producer must show the failure
1340
+ // rather than filter out an edge whose other end is unknown.
1341
+ const unasked = (reason) => filed(focusScope && consumer.id !== focusScope.id
1342
+ ? edge(focusScope.vertical, { state: 'unavailable', reason, producer: { vertical: focusScope.vertical, scopeId: focusScope.id } })
1343
+ : edge('*', { state: 'unavailable', reason }), '*');
1344
+ if (!stateRead.ok) {
1345
+ report.edges.push(unasked(`could not ask this app what it imports: ${stateRead.error}`));
1346
+ return;
1347
+ }
1348
+ const state = stateRead.state;
1349
+ if (state.consumes.length === 0) {
1350
+ if (knownImporters.has(consumer.id)) {
1351
+ report.edges.push(unasked("the version registry says this app's code imports events, but its deployment answers that it " +
1352
+ 'imports nothing — it is not running the version the registry names; redeploy or reconcile it'));
1353
+ }
1354
+ return;
1355
+ }
1356
+ // Unreadable is not "open": the edge keeps its probe's state, and the reason says so.
1357
+ const door = doorRead.ok
1358
+ ? new Map(doorRead.entries.map((e) => [e.vertical, { calls: e.calls, reason: e.switchedOff?.reason ?? null }]))
1359
+ : null;
1360
+ const sources = [...wantsBySource(state.consumes, wanted)];
1361
+ await mapBounded(sources, concurrency, async ([from, wants]) => {
1362
+ report.edges.push(filed(await edgeHealthOf(from, wants), from));
1363
+ });
1364
+ async function edgeHealthOf(from, wants) {
1365
+ const ends = edgeEnds(scopes, consumer, from);
1366
+ if ('reason' in ends)
1367
+ return edge(from, { state: 'unresolved', reason: ends.reason });
1368
+ const { source } = ends;
1369
+ const cursor = state.cursors.find((c) => c.source === source.scopeId) ?? null;
1370
+ const at = {
1371
+ producer: source,
1372
+ watermark: cursor?.cursor ? { cursor: cursor.cursor, updatedAt: cursor.updatedAt } : null,
1373
+ };
1374
+ let probe;
1375
+ try {
1376
+ probe = await reach.readExports(tenantId, source.scopeId, {
1377
+ consumer: vertical,
1378
+ after: cursor?.cursor ?? null,
1379
+ wants,
1380
+ limit: 1,
1381
+ });
1382
+ }
1383
+ catch (err) {
1384
+ return edge(from, { ...at, state: 'unavailable', reason: `could not ask '${from}' what is waiting: ${message(err)}` });
1385
+ }
1386
+ const unexported = probe.unexported;
1387
+ if (probe.paused) {
1388
+ return edge(from, {
1389
+ ...at,
1390
+ unexported,
1391
+ state: 'paused',
1392
+ reason: `'${from}' does not grant vertical:${vertical} ${probe.paused.missing.join(', ')} — nothing moves; the backlog waits in its outbox`,
1393
+ });
1394
+ }
1395
+ const first = [...probe.events, ...probe.withheld].sort((a, b) => (a.id < b.id ? -1 : 1))[0];
1396
+ const oldestPending = first ? { id: first.id, occurredAt: first.occurredAt } : null;
1397
+ const occurred = first ? Date.parse(first.occurredAt) : NaN;
1398
+ const lagMs = first && Number.isFinite(occurred) ? Math.max(0, Math.floor(now() - occurred)) : null;
1399
+ const pending = { ...at, unexported, oldestPending, lagMs };
1400
+ const gate = door?.get(from);
1401
+ if (gate && gate.calls !== 'on') {
1402
+ return edge(from, {
1403
+ ...pending,
1404
+ state: 'paused',
1405
+ reason: gate.calls === 'off'
1406
+ ? `this app has '${from}' switched off${gate.reason ? ` (${gate.reason})` : ''} — nothing is delivered; the backlog waits in the producer's outbox`
1407
+ : `this app holds no grant for '${from}' — its door refuses the delivery; the backlog waits in the producer's outbox`,
1408
+ });
1409
+ }
1410
+ // Caught up means nothing is waiting, which an unread door cannot change, so its reason is
1411
+ // null as the contract says. Behind with an unread door says so: whether the next pass
1412
+ // delivers depends on it.
1413
+ if (!first)
1414
+ return edge(from, { ...pending, state: 'caught-up', reason: null });
1415
+ const doorNote = doorRead.ok ? '' : ` (whether this app admits '${from}' could not be read: ${doorRead.error})`;
1416
+ return edge(from, {
1417
+ ...pending,
1418
+ state: 'behind',
1419
+ reason: `events are waiting past the watermark; the next pass takes them${doorNote}`,
1420
+ });
1421
+ }
1422
+ });
1423
+ // The history, read per edge: the last pass that delivered, and the last that did not when it is
1424
+ // newer. Per edge rather than one window over the tenant, so a noisy edge's rows cannot push a
1425
+ // quiet edge's last delivery out of the read. One read when the newest row is a delivery, two
1426
+ // otherwise. A failed read costs the history, never the live state.
1427
+ await mapBounded(report.edges, concurrency, async (e) => {
1428
+ const unit = unitOf.get(e);
1429
+ if (unit === undefined)
1430
+ return;
1431
+ try {
1432
+ const runs = (filter) => host.admin.listSweepRuns(actor, { kind: 'vertical-events', tenantId, unit, limit: 1, ...filter });
1433
+ const [latest] = await runs({});
1434
+ if (!latest)
1435
+ return;
1436
+ if (latest.outcome === 'ok') {
1437
+ e.lastDelivered = { at: latest.at };
1438
+ return;
1439
+ }
1440
+ const [ok] = await runs({ outcome: 'ok' });
1441
+ e.lastDelivered = ok ? { at: ok.at } : null;
1442
+ e.lastProblem = { at: latest.at, outcome: latest.outcome, error: latest.error ?? null };
1443
+ }
1444
+ catch (err) {
1445
+ report.history = { available: false, reason: `the sweep history could not be read: ${message(err)}` };
1446
+ }
1447
+ });
1448
+ report.edges.sort((a, b) => a.consumer.vertical.localeCompare(b.consumer.vertical) ||
1449
+ a.consumer.scopeId.localeCompare(b.consumer.scopeId) ||
1450
+ a.producer.vertical.localeCompare(b.producer.vertical));
1451
+ return report;
1452
+ }
924
1453
  /**
925
1454
  * One read→ship→stamp cycle over ONE scope's outbox (#1334). Returns how many
926
1455
  * events shipped, so the caller can tell a full batch (more waiting) from a