feat(commons): project outbound API/DB/notification rows onto the External lane

ResolveKind maps ApiOutbound/DbOutbound/Notification to ApiCall/DbCall/Notify;
ResolveLane puts the three on FlowLane.External; outbound outcomes map
Delivered→Ok, Failed→Failed, Parked/Discarded→Parked, else Pending; subtitles
'{status} · sync|cached', '{op} · n rows' from a DbWrite Extra, '{status}' for
Notify. New FlowStepProjector.IsOutbound helper.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 01:38:41 -04:00
parent cc1bbf3975
commit e26616bebc
3 changed files with 163 additions and 5 deletions
@@ -1,3 +1,4 @@
using System.Text.Json;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -73,17 +74,34 @@ public static class FlowStepProjector
return FlowStepKind.Unknown;
}
/// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags.</summary>
/// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags; outbound API / DB / notify → External.</summary>
public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch
{
FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central,
FlowStepKind.ScriptRun or FlowStepKind.AttributeWait => FlowLane.Site,
FlowStepKind.AttributeWrite => FlowLane.Tags,
FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify => FlowLane.External,
_ => string.IsNullOrEmpty(sourceSiteId) ? FlowLane.Central : FlowLane.Site,
};
/// <summary>True for the three outbound kinds that live on the External lane.</summary>
public static bool IsOutbound(FlowStepKind kind)
=> kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
private static FlowStepOutcome ResolveOutcome(FlowStepKind kind, string? status, string? error)
{
if (IsOutbound(kind))
{
return status switch
{
nameof(AuditStatus.Delivered) => FlowStepOutcome.Ok,
nameof(AuditStatus.Failed) => FlowStepOutcome.Failed,
nameof(AuditStatus.Parked) or nameof(AuditStatus.Discarded) => FlowStepOutcome.Parked,
// Submitted / Forwarded / Attempted — and anything this build does not know — is still in flight.
_ => FlowStepOutcome.Pending,
};
}
if (!string.Equals(status, nameof(AuditStatus.Failed), StringComparison.Ordinal))
return FlowStepOutcome.Ok;
if ((kind == FlowStepKind.ScriptRun || kind == FlowStepKind.AttributeWait)
@@ -107,6 +125,45 @@ public static class FlowStepProjector
: extra.WaitOutcome is null
? extra.ObservedValue
: extra.ObservedValue is null ? extra.WaitOutcome : $"{extra.WaitOutcome} · {extra.ObservedValue}",
FlowStepKind.ApiCall => string.Equals(d.Kind, nameof(AuditKind.ApiCall), StringComparison.Ordinal)
? $"{d.Status} · sync"
: $"{d.Status} · cached",
FlowStepKind.DbCall => DbSubtitle(d),
FlowStepKind.Notify => d.Status,
_ => d.Kind,
};
/// <summary>
/// A sync <c>DbWrite</c> row carries <c>{"op":"write","rowsAffected":n}</c> / <c>{"op":"read","rowsReturned":n}</c>
/// in <c>Extra</c> (site <c>AuditingDbCommand</c> vocabulary); cached rows carry none and read as <c>"{status} · cached"</c>.
/// </summary>
private static string? DbSubtitle(AuditDetails d)
{
if (string.Equals(d.Kind, nameof(AuditKind.DbWrite), StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(d.Extra))
{
try
{
using var doc = JsonDocument.Parse(d.Extra);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("op", out var opEl) && opEl.ValueKind == JsonValueKind.String)
{
var op = opEl.GetString();
int? rows = null;
if (root.TryGetProperty("rowsAffected", out var ra) && ra.ValueKind == JsonValueKind.Number) rows = ra.GetInt32();
else if (root.TryGetProperty("rowsReturned", out var rr) && rr.ValueKind == JsonValueKind.Number) rows = rr.GetInt32();
if (rows is { } n && n >= 0)
return $"{op} · {n} {(n == 1 ? "row" : "rows")}";
return op;
}
}
catch (JsonException)
{
// Fall through — an unparseable Extra never fails projection.
}
}
return string.Equals(d.Kind, nameof(AuditKind.DbWrite), StringComparison.Ordinal)
? d.Status
: $"{d.Status} · cached";
}
}