feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

View File

@@ -0,0 +1,169 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal enum DevelopmentDatabaseSmokeStage
{
None,
Preflight,
Create,
CreateReadback,
Save,
SaveReadback,
Cleanup,
VerifyAbsent
}
internal sealed record DevelopmentDatabaseSmokeResult(
string ScenarioName,
bool Passed,
DevelopmentDatabaseSmokeStage FailureStage,
string FailureClass,
bool OutcomeUnknown,
bool CleanupAttempted,
bool CleanupVerified,
bool ManualCleanupMayBeRequired);
/// <summary>
/// Runs one collision-isolated development write scenario. Every delegate is
/// invoked at most once. A mutation with an unknown outcome quarantines the
/// scenario immediately: no cleanup mutation or later scenario may be issued.
/// </summary>
internal sealed class DevelopmentDatabaseSmokeScenario
{
private readonly Func<CancellationToken, Task> _preflight;
private readonly Func<CancellationToken, Task> _create;
private readonly Func<CancellationToken, Task> _verifyCreate;
private readonly Func<CancellationToken, Task>? _save;
private readonly Func<CancellationToken, Task>? _verifySave;
private readonly Func<CancellationToken, Task> _cleanup;
private readonly Func<CancellationToken, Task> _verifyAbsent;
private readonly Func<Exception, bool> _isOutcomeUnknown;
internal DevelopmentDatabaseSmokeScenario(
string name,
Func<CancellationToken, Task> preflight,
Func<CancellationToken, Task> create,
Func<CancellationToken, Task> verifyCreate,
Func<CancellationToken, Task>? save,
Func<CancellationToken, Task>? verifySave,
Func<CancellationToken, Task> cleanup,
Func<CancellationToken, Task> verifyAbsent,
Func<Exception, bool> isOutcomeUnknown)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if ((save is null) != (verifySave is null))
{
throw new ArgumentException(
"Save and save-readback delegates must either both be present or both be absent.",
nameof(save));
}
Name = name;
_preflight = preflight ?? throw new ArgumentNullException(nameof(preflight));
_create = create ?? throw new ArgumentNullException(nameof(create));
_verifyCreate = verifyCreate ?? throw new ArgumentNullException(nameof(verifyCreate));
_save = save;
_verifySave = verifySave;
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
_verifyAbsent = verifyAbsent ?? throw new ArgumentNullException(nameof(verifyAbsent));
_isOutcomeUnknown = isOutcomeUnknown ??
throw new ArgumentNullException(nameof(isOutcomeUnknown));
}
internal string Name { get; }
internal async Task<DevelopmentDatabaseSmokeResult> RunAsync(
CancellationToken cancellationToken = default)
{
var currentStage = DevelopmentDatabaseSmokeStage.Preflight;
Exception? primaryFailure = null;
var failureStage = DevelopmentDatabaseSmokeStage.None;
var outcomeUnknown = false;
var failureClass = "none";
var createKnownCommitted = false;
var cleanupAttempted = false;
var cleanupVerified = false;
try
{
try
{
await _preflight(cancellationToken).ConfigureAwait(false);
currentStage = DevelopmentDatabaseSmokeStage.Create;
await _create(cancellationToken).ConfigureAwait(false);
createKnownCommitted = true;
currentStage = DevelopmentDatabaseSmokeStage.CreateReadback;
await _verifyCreate(cancellationToken).ConfigureAwait(false);
if (_save is not null)
{
currentStage = DevelopmentDatabaseSmokeStage.Save;
await _save(cancellationToken).ConfigureAwait(false);
currentStage = DevelopmentDatabaseSmokeStage.SaveReadback;
await _verifySave!(cancellationToken).ConfigureAwait(false);
}
}
catch (Exception exception)
{
primaryFailure = exception;
failureStage = currentStage;
failureClass = RedactedDatabaseFailureClassifier.Classify(exception);
outcomeUnknown = _isOutcomeUnknown(exception);
}
}
finally
{
// A known successful create is cleaned up even when a later read-only
// verification fails. An unknown mutation outcome is quarantined and
// never followed by another mutation.
if (createKnownCommitted && !outcomeUnknown)
{
cleanupAttempted = true;
try
{
currentStage = DevelopmentDatabaseSmokeStage.Cleanup;
await _cleanup(CancellationToken.None).ConfigureAwait(false);
currentStage = DevelopmentDatabaseSmokeStage.VerifyAbsent;
await _verifyAbsent(CancellationToken.None).ConfigureAwait(false);
cleanupVerified = true;
}
catch (Exception exception)
{
primaryFailure = exception;
failureStage = currentStage;
failureClass = RedactedDatabaseFailureClassifier.Classify(exception);
outcomeUnknown = _isOutcomeUnknown(exception);
}
}
}
if (primaryFailure is null && cleanupVerified)
{
return new DevelopmentDatabaseSmokeResult(
Name,
Passed: true,
DevelopmentDatabaseSmokeStage.None,
"none",
OutcomeUnknown: false,
CleanupAttempted: true,
CleanupVerified: true,
ManualCleanupMayBeRequired: false);
}
return new DevelopmentDatabaseSmokeResult(
Name,
Passed: false,
failureStage,
failureClass,
outcomeUnknown,
cleanupAttempted,
cleanupVerified,
ManualCleanupMayBeRequired: outcomeUnknown &&
(createKnownCommitted || failureStage == DevelopmentDatabaseSmokeStage.Create));
}
}

View File

@@ -0,0 +1,108 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal static class DevelopmentDatabaseWriteSmokeApp
{
internal const int SuccessExitCode = 0;
internal const int FailureExitCode = 1;
internal const int NotAcknowledgedExitCode = 2;
internal const int OutcomeUnknownExitCode = 3;
internal static async Task<int> RunAsync(
IReadOnlyList<string> arguments,
Func<string?, IReadOnlyList<DevelopmentDatabaseSmokeScenario>> createPlan,
TextWriter output,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(createPlan);
ArgumentNullException.ThrowIfNull(output);
if (!SmokeCommandLine.TryParse(arguments, out var options))
{
await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false);
return NotAcknowledgedExitCode;
}
if (options.ShowHelp)
{
await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false);
await output.WriteLineAsync(
"No write occurs unless the exact development-database acknowledgement is present.")
.ConfigureAwait(false);
return SuccessExitCode;
}
if (!options.IsWriteAcknowledged)
{
await output.WriteLineAsync(
"BLOCKED: development database writes were not acknowledged; no configuration was loaded and no database call was made.")
.ConfigureAwait(false);
await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false);
return NotAcknowledgedExitCode;
}
IReadOnlyList<DevelopmentDatabaseSmokeScenario> plan;
try
{
plan = createPlan(options.ConfigurationPath);
}
catch
{
await output.WriteLineAsync(
"FAIL: configuration or smoke-plan creation failed (details redacted).")
.ConfigureAwait(false);
return FailureExitCode;
}
await output.WriteLineAsync(
"START: isolated development DB write/readback/cleanup smoke; credentials, SQL, identifiers, and values are redacted.")
.ConfigureAwait(false);
foreach (var scenario in plan)
{
cancellationToken.ThrowIfCancellationRequested();
await output.WriteLineAsync("RUN: " + scenario.Name).ConfigureAwait(false);
DevelopmentDatabaseSmokeResult result;
try
{
result = await scenario.RunAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
await output.WriteLineAsync(
"FAIL: unclassified smoke failure; no retry was attempted (details redacted).")
.ConfigureAwait(false);
return FailureExitCode;
}
if (result.Passed)
{
await output.WriteLineAsync(
"PASS: create/save, fresh readback, cleanup, and absence verification completed.")
.ConfigureAwait(false);
continue;
}
await output.WriteLineAsync(
$"FAIL: stage={result.FailureStage}; failureClass={result.FailureClass}; cleanupAttempted={result.CleanupAttempted}; cleanupVerified={result.CleanupVerified}; details redacted.")
.ConfigureAwait(false);
if (result.OutcomeUnknown)
{
await output.WriteLineAsync(
"QUARANTINED: mutation outcome is unknown; no retry, cleanup mutation, or later scenario was issued. A uniquely prefixed record may require manual inspection.")
.ConfigureAwait(false);
return OutcomeUnknownExitCode;
}
return FailureExitCode;
}
await output.WriteLineAsync(
"PASS: every development DB write scenario was verified absent after cleanup.")
.ConfigureAwait(false);
return SuccessExitCode;
}
}

View File

@@ -0,0 +1,98 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
/// <summary>
/// The legacy deletion workflows remove child rows by numeric parent key. This
/// read-only guard proves a newly suggested key has no pre-existing orphaned
/// children before a smoke record can adopt that key.
/// </summary>
internal sealed class ExistingChildRowGuard
{
private const string ResultColumn = "ROW_COUNT";
private readonly IDataQueryExecutor _executor;
internal ExistingChildRowGuard(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
internal Task EnsureNamedPlaylistChildrenAbsentAsync(
string programCode,
CancellationToken cancellationToken) =>
EnsureZeroAsync(
DataSourceKind.Oracle,
"DB_WRITE_SMOKE_PLAYLIST_CHILD_PREFLIGHT",
"SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM PLAY_LIST WHERE PG_CODE = :parent_code",
programCode,
cancellationToken);
internal Task EnsureThemeChildrenAbsentAsync(
ThemeMarket market,
string themeCode,
CancellationToken cancellationToken) =>
market switch
{
ThemeMarket.Krx => EnsureZeroAsync(
DataSourceKind.Oracle,
"DB_WRITE_SMOKE_KRX_THEME_CHILD_PREFLIGHT",
"SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM SB_ITEM WHERE SB_M_CODE = :parent_code",
themeCode,
cancellationToken),
ThemeMarket.Nxt => EnsureZeroAsync(
DataSourceKind.MariaDb,
"DB_WRITE_SMOKE_NXT_THEME_CHILD_PREFLIGHT",
"SELECT CAST(COUNT(*) AS CHAR) ROW_COUNT FROM SB_ITEM WHERE SB_M_CODE = @parent_code",
themeCode,
cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(market))
};
internal Task EnsureExpertChildrenAbsentAsync(
string expertCode,
CancellationToken cancellationToken) =>
EnsureZeroAsync(
DataSourceKind.Oracle,
"DB_WRITE_SMOKE_EXPERT_CHILD_PREFLIGHT",
"SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM RECOMMEND_LIST WHERE RC_CODE = :parent_code",
expertCode,
cancellationToken);
private async Task EnsureZeroAsync(
DataSourceKind source,
string queryName,
string sql,
string parentCode,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(parentCode);
var spec = new DataQuerySpec(
sql,
[new DataQueryParameter("parent_code", parentCode, DbType.String)]);
spec.ValidateFor(source);
var table = await _executor.ExecuteAsync(
source,
queryName,
spec,
cancellationToken).ConfigureAwait(false);
if (table is null ||
table.Columns.Count != 1 ||
table.Rows.Count != 1 ||
!string.Equals(
table.Columns[0].ColumnName,
ResultColumn,
StringComparison.Ordinal) ||
table.Columns[0].DataType != typeof(string) ||
table.Rows[0][0] is not string count ||
!string.Equals(count, "0", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"A suggested numeric key has existing child rows or an invalid preflight result.");
}
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>MBN_STOCK_WEBVIEW.DbWriteSmoke</AssemblyName>
<RootNamespace>MBN_STOCK_WEBVIEW.DbWriteSmoke</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,172 @@
#nullable enable
using System.Text.RegularExpressions;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal static partial class OwnedManualSmokeResidueRecovery
{
internal const string RecoveryOption = "--reconcile-known-owned-manual-residue";
private static readonly IReadOnlyDictionary<ManualFinancialScreenKind, char> ScenarioCodes =
new Dictionary<ManualFinancialScreenKind, char>
{
[ManualFinancialScreenKind.RevenueComposition] = 'P',
[ManualFinancialScreenKind.GrowthMetrics] = 'G',
[ManualFinancialScreenKind.Sales] = 'S',
[ManualFinancialScreenKind.OperatingProfit] = 'O'
};
internal static bool IsRequested(IReadOnlyList<string> arguments) =>
arguments.Contains(RecoveryOption, StringComparer.Ordinal);
internal static async Task<int> RunAsync(
IReadOnlyList<string> arguments,
TextWriter output,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(output);
if (!TryParse(arguments, out var configurationPath))
{
await output.WriteLineAsync(
"Usage: MBN_STOCK_WEBVIEW.DbWriteSmoke " + RecoveryOption + " " +
SmokeCommandLine.WriteAcknowledgement +
" [" + SmokeCommandLine.ConfigurationOption + " <database.local.json>]")
.ConfigureAwait(false);
return DevelopmentDatabaseWriteSmokeApp.NotAcknowledgedExitCode;
}
try
{
var options = new DatabaseOptionsLoader().Load(configurationPath);
options.Resilience.MaximumRetryCount = 0;
options.Resilience.InitialRetryDelayMilliseconds = 0;
var runtime = DatabaseRuntime.Create(options);
var service = new LegacyManualFinancialScreenService(
runtime.Executor,
new OracleManualFinancialMutationExecutor(
runtime.ConnectionFactory,
options.Resilience));
var prefix = SmokeRunIdentity.Prefix +
DateTimeOffset.UtcNow.ToString("yyMMdd", System.Globalization.CultureInfo.InvariantCulture);
var total = 0;
await output.WriteLineAsync(
"START: reconcile only the closed, current-UTC-date manual smoke namespace; details redacted.")
.ConfigureAwait(false);
foreach (var (screen, scenarioCode) in ScenarioCodes)
{
var result = await service.SearchAsync(
screen,
prefix,
LegacyManualFinancialScreenService.MaximumResults,
cancellationToken)
.ConfigureAwait(false);
var owned = result.Items.Where(item =>
IsOwned(item.Record.Identity.StockName, prefix, scenarioCode))
.ToArray();
if (result.IsTruncated || owned.Length > 1)
{
await output.WriteLineAsync(
"BLOCKED: the owned residue set was not singular and complete; writesAttempted=false.")
.ConfigureAwait(false);
return DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
}
if (owned.Length == 0)
{
continue;
}
await service.DeleteAsync(owned[0], cancellationToken).ConfigureAwait(false);
total++;
try
{
_ = await service.GetAsync(
owned[0].Record.Identity,
cancellationToken)
.ConfigureAwait(false);
}
catch (ManualFinancialNotFoundException)
{
continue;
}
await output.WriteLineAsync(
"FAIL: an owned residue remained after one delete; no retry was issued.")
.ConfigureAwait(false);
return DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
}
await output.WriteLineAsync(
$"PASS: reconciled={total}; every owned residue is absent; no retry was issued.")
.ConfigureAwait(false);
return DevelopmentDatabaseWriteSmokeApp.SuccessExitCode;
}
catch (ManualFinancialMutationException exception)
{
await output.WriteLineAsync(
"FAIL: failureClass=" + RedactedDatabaseFailureClassifier.Classify(exception) +
"; outcomeUnknown=" + exception.OutcomeUnknown.ToString().ToLowerInvariant() +
"; no retry was issued.")
.ConfigureAwait(false);
return exception.OutcomeUnknown
? DevelopmentDatabaseWriteSmokeApp.OutcomeUnknownExitCode
: DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
}
catch (Exception exception)
{
await output.WriteLineAsync(
"FAIL: failureClass=" + RedactedDatabaseFailureClassifier.Classify(exception) +
"; no retry was issued; details redacted.")
.ConfigureAwait(false);
return DevelopmentDatabaseWriteSmokeApp.FailureExitCode;
}
}
private static bool IsOwned(string value, string prefix, char scenarioCode) =>
value.Length == 20 &&
value.StartsWith(prefix, StringComparison.Ordinal) &&
value[^1] == scenarioCode &&
OwnedNamePattern().IsMatch(value);
private static bool TryParse(
IReadOnlyList<string> arguments,
out string? configurationPath)
{
configurationPath = null;
var recovery = false;
var acknowledged = false;
for (var index = 0; index < arguments.Count; index++)
{
switch (arguments[index])
{
case RecoveryOption when !recovery:
recovery = true;
break;
case SmokeCommandLine.WriteAcknowledgement when !acknowledged:
acknowledged = true;
break;
case SmokeCommandLine.ConfigurationOption when configurationPath is null &&
index + 1 < arguments.Count:
configurationPath = arguments[++index];
if (string.IsNullOrWhiteSpace(configurationPath))
{
return false;
}
break;
default:
return false;
}
}
return recovery && acknowledged;
}
[GeneratedRegex("^MBW[0-9]{6}[0-9A-F]{10}[PGSO]$", RegexOptions.CultureInvariant)]
private static partial Regex OwnedNamePattern();
}

View File

@@ -0,0 +1,559 @@
#nullable enable
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal static class ProductionDevelopmentDatabaseSmokePlan
{
internal static IReadOnlyList<DevelopmentDatabaseSmokeScenario> Create(
string? configurationPath)
{
var options = new DatabaseOptionsLoader().Load(configurationPath);
// This executable never inherits the production read retry count. A
// timeout is surfaced once so the harness cannot conceal an uncertain
// state transition behind another attempt.
options.Resilience.MaximumRetryCount = 0;
options.Resilience.InitialRetryDelayMilliseconds = 0;
var runtime = DatabaseRuntime.Create(options);
var manualMutation = new OracleManualFinancialMutationExecutor(
runtime.ConnectionFactory,
options.Resilience);
var namedMutation = new OracleNamedPlaylistMutationExecutor(
runtime.ConnectionFactory,
options.Resilience);
var catalogMutation = new OperatorCatalogMutationExecutor(
runtime.ConnectionFactory,
options.Resilience);
var manual = new LegacyManualFinancialScreenService(
runtime.Executor,
manualMutation);
var named = new LegacyNamedPlaylistPersistenceService(
runtime.Executor,
namedMutation);
var themePersistence = new LegacyThemeCatalogPersistenceService(
runtime.Executor,
catalogMutation);
var themeSelection = new LegacyThemeSelectionService(runtime.Executor);
var expertPersistence = new LegacyExpertCatalogPersistenceService(
runtime.Executor,
catalogMutation);
var expertSelection = new LegacyExpertSelectionService(runtime.Executor);
var childGuard = new ExistingChildRowGuard(runtime.Executor);
var run = SmokeRunIdentity.Create(DateTimeOffset.UtcNow, Guid.NewGuid());
return
[
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.RevenueComposition, run.For("G_PIE"))),
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.GrowthMetrics, run.For("G_GROW"))),
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.Sales, run.For("G_SELL"))),
CreateManualScenario(
manual,
CreateManualRecord(ManualFinancialScreenKind.OperatingProfit, run.For("G_PROFIT"))),
CreateNamedPlaylistScenario(named, childGuard, run.For("PLAYLIST")),
CreateThemeScenario(
themePersistence,
themeSelection,
childGuard,
ThemeMarket.Krx,
run.For("THEME_KRX")),
CreateThemeScenario(
themePersistence,
themeSelection,
childGuard,
ThemeMarket.Nxt,
run.For("THEME_NXT")),
CreateExpertScenario(
expertPersistence,
expertSelection,
childGuard,
run.For("EXPERT"))
];
}
private static DevelopmentDatabaseSmokeScenario CreateManualScenario(
IManualFinancialScreenService service,
ManualFinancialRecord expectedRecord)
{
var identity = expectedRecord.Identity;
ManualFinancialSnapshot? freshSnapshot = null;
return new DevelopmentDatabaseSmokeScenario(
"GraphE/" + identity.Screen,
async cancellationToken =>
{
var search = await service.SearchAsync(
identity.Screen,
identity.StockName,
10,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated || search.Items.Any(item =>
string.Equals(
item.Record.Identity.StockName,
identity.StockName,
StringComparison.Ordinal)))
{
throw Collision("GraphE preflight did not prove absence.");
}
},
cancellationToken => IgnoreResultAsync(
service.CreateAsync(expectedRecord, cancellationToken)),
async cancellationToken =>
{
freshSnapshot = await service.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
VerifyManualRecord(expectedRecord, freshSnapshot);
},
save: null,
verifySave: null,
async cancellationToken =>
{
freshSnapshot ??= await service.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
await service.DeleteAsync(freshSnapshot, cancellationToken).ConfigureAwait(false);
},
cancellationToken => EnsureManualAbsentAsync(service, identity, cancellationToken),
IsOutcomeUnknown);
}
private static DevelopmentDatabaseSmokeScenario CreateNamedPlaylistScenario(
INamedPlaylistPersistenceService service,
ExistingChildRowGuard childGuard,
string title)
{
string? programCode = null;
var savedItem = new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
"KOSPI",
"SMOKE",
"PLATE",
"CURRENT",
string.Empty),
new NamedPlaylistPageState(1, 1));
return new DevelopmentDatabaseSmokeScenario(
"AList/PList named playlist",
async cancellationToken =>
{
programCode = await service.SuggestNextProgramCodeAsync(cancellationToken)
.ConfigureAwait(false);
var list = await service.ListAsync(
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
cancellationToken).ConfigureAwait(false);
if (list.IsTruncated || list.Items.Any(item =>
string.Equals(item.Title, title, StringComparison.Ordinal)))
{
throw Collision("Named-playlist title absence was not proven.");
}
await EnsureNamedPlaylistAbsentAsync(
service,
programCode,
cancellationToken).ConfigureAwait(false);
await childGuard.EnsureNamedPlaylistChildrenAbsentAsync(
programCode,
cancellationToken).ConfigureAwait(false);
},
cancellationToken => service.CreateDefinitionAsync(
Required(programCode),
title,
cancellationToken),
async cancellationToken =>
{
var document = await service.LoadAsync(
Required(programCode),
cancellationToken).ConfigureAwait(false);
if (!string.Equals(
document.Definition.Title,
title,
StringComparison.Ordinal) ||
document.Items.Count != 0)
{
throw Verification("Named-playlist create readback did not match.");
}
},
cancellationToken => service.ReplaceItemsAsync(
Required(programCode),
[savedItem],
cancellationToken),
async cancellationToken =>
{
var document = await service.LoadAsync(
Required(programCode),
cancellationToken).ConfigureAwait(false);
if (!string.Equals(
document.Definition.Title,
title,
StringComparison.Ordinal) ||
document.Items.Count != 1 ||
document.Items[0] != savedItem)
{
throw Verification("Named-playlist save readback did not match.");
}
},
cancellationToken => service.DeleteAsync(
Required(programCode),
cancellationToken),
async cancellationToken =>
{
await EnsureNamedPlaylistAbsentAsync(
service,
Required(programCode),
cancellationToken).ConfigureAwait(false);
var list = await service.ListAsync(
LegacyNamedPlaylistPersistenceService.MaximumDefinitions,
cancellationToken).ConfigureAwait(false);
if (list.IsTruncated || list.Items.Any(item =>
string.Equals(item.Title, title, StringComparison.Ordinal)))
{
throw Verification("Named-playlist cleanup absence was not proven.");
}
},
IsOutcomeUnknown);
}
private static DevelopmentDatabaseSmokeScenario CreateThemeScenario(
IThemeCatalogPersistenceService persistence,
IThemeSelectionService selection,
ExistingChildRowGuard childGuard,
ThemeMarket market,
string title)
{
string? themeCode = null;
ThemeSelectionIdentity? freshIdentity = null;
var session = market == ThemeMarket.Krx
? ThemeSession.NotApplicable
: ThemeSession.PreMarket;
return new DevelopmentDatabaseSmokeScenario(
"ThemeA/" + market,
async cancellationToken =>
{
await EnsureThemeAbsentAsync(selection, title, cancellationToken)
.ConfigureAwait(false);
themeCode = await persistence.SuggestNextCodeAsync(market, cancellationToken)
.ConfigureAwait(false);
await childGuard.EnsureThemeChildrenAbsentAsync(
market,
themeCode,
cancellationToken).ConfigureAwait(false);
},
cancellationToken => IgnoreResultAsync(
persistence.CreateAsync(
new ThemeCatalogDefinition(
market,
Required(themeCode),
title,
[]),
cancellationToken)),
async cancellationToken =>
{
var search = await selection.SearchAsync(
title,
ThemeSession.PreMarket,
LegacyThemeSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated)
{
throw Verification("Theme readback was truncated.");
}
var matches = search.Items.Where(item =>
string.Equals(
item.Identity.ThemeTitle,
title,
StringComparison.Ordinal)).ToArray();
if (matches.Length != 1 ||
matches[0].Identity.Market != market ||
!string.Equals(
matches[0].Identity.ThemeCode,
themeCode,
StringComparison.Ordinal))
{
throw Verification("Theme create readback did not match.");
}
freshIdentity = matches[0].Identity with { Session = session };
var preview = await selection.PreviewAsync(
freshIdentity,
LegacyThemeSelectionService.MaximumPreviewItems,
cancellationToken).ConfigureAwait(false);
if (preview.Items.Count != 0 || preview.IsTruncated)
{
throw Verification("Theme preview readback did not match.");
}
},
save: null,
verifySave: null,
cancellationToken => IgnoreResultAsync(
persistence.DeleteAsync(
freshIdentity ?? new ThemeSelectionIdentity(
market,
session,
Required(themeCode),
title),
cancellationToken)),
cancellationToken => EnsureThemeAbsentAsync(selection, title, cancellationToken),
IsOutcomeUnknown);
}
private static DevelopmentDatabaseSmokeScenario CreateExpertScenario(
IExpertCatalogPersistenceService persistence,
IExpertSelectionService selection,
ExistingChildRowGuard childGuard,
string name)
{
string? expertCode = null;
ExpertSelectionIdentity? freshIdentity = null;
return new DevelopmentDatabaseSmokeScenario(
"EList expert catalog",
async cancellationToken =>
{
await EnsureExpertAbsentAsync(selection, name, cancellationToken)
.ConfigureAwait(false);
expertCode = await persistence.SuggestNextCodeAsync(cancellationToken)
.ConfigureAwait(false);
await childGuard.EnsureExpertChildrenAbsentAsync(
expertCode,
cancellationToken).ConfigureAwait(false);
},
cancellationToken => IgnoreResultAsync(
persistence.CreateAsync(
new ExpertCatalogDefinition(
new ExpertSelectionIdentity(Required(expertCode), name),
[]),
cancellationToken)),
async cancellationToken =>
{
var search = await selection.SearchAsync(
name,
LegacyExpertSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated)
{
throw Verification("Expert readback was truncated.");
}
var matches = search.Items.Where(item =>
string.Equals(
item.Identity.ExpertName,
name,
StringComparison.Ordinal)).ToArray();
if (matches.Length != 1 ||
!string.Equals(
matches[0].Identity.ExpertCode,
expertCode,
StringComparison.Ordinal))
{
throw Verification("Expert create readback did not match.");
}
freshIdentity = matches[0].Identity;
var preview = await selection.PreviewAsync(
freshIdentity,
LegacyExpertSelectionService.MaximumPreviewItems,
cancellationToken).ConfigureAwait(false);
if (preview.Items.Count != 0 || preview.IsTruncated)
{
throw Verification("Expert preview readback did not match.");
}
},
save: null,
verifySave: null,
cancellationToken => IgnoreResultAsync(
persistence.DeleteAsync(
freshIdentity ?? new ExpertSelectionIdentity(
Required(expertCode),
name),
cancellationToken)),
cancellationToken => EnsureExpertAbsentAsync(selection, name, cancellationToken),
IsOutcomeUnknown);
}
private static ManualFinancialRecord CreateManualRecord(
ManualFinancialScreenKind screen,
string stockName)
{
var identity = new ManualFinancialIdentity(screen, stockName);
return screen switch
{
ManualFinancialScreenKind.RevenueComposition =>
new ManualRevenueCompositionRecord(
identity,
"2026-07",
[new("SMOKE", "100", 100), null, null, null, null]),
ManualFinancialScreenKind.GrowthMetrics =>
new ManualGrowthMetricsRecord(
identity,
new(1, 2, 3, 4),
new(5, 6, 7, 8),
new(9, 10, 11, 12),
new(13, 14, 15, 16),
new("P1", "P2", "P3", "P4")),
ManualFinancialScreenKind.Sales =>
new ManualSalesRecord(identity, SixQuarters()),
ManualFinancialScreenKind.OperatingProfit =>
new ManualOperatingProfitRecord(identity, SixQuarters()),
_ => throw new ArgumentOutOfRangeException(nameof(screen))
};
}
private static IReadOnlyList<ManualQuarterValue> SixQuarters() =>
[
new("Q1", 10),
new("Q2", -20),
new("Q3", 30),
new("Q4", 40),
new("Q5", 50),
new("Q6", 60)
];
private static void VerifyManualRecord(
ManualFinancialRecord expected,
ManualFinancialSnapshot actual)
{
if (string.IsNullOrWhiteSpace(actual.RowVersion) ||
actual.Record.Identity != expected.Identity)
{
throw Verification("GraphE identity or row version did not match.");
}
var matches = (expected, actual.Record) switch
{
(ManualRevenueCompositionRecord left, ManualRevenueCompositionRecord right) =>
string.Equals(left.BaseDate, right.BaseDate, StringComparison.Ordinal) &&
left.Slices.SequenceEqual(right.Slices),
(ManualGrowthMetricsRecord left, ManualGrowthMetricsRecord right) =>
left.SalesGrowth == right.SalesGrowth &&
left.OperatingProfitGrowth == right.OperatingProfitGrowth &&
left.NetAssetGrowth == right.NetAssetGrowth &&
left.NetIncomeGrowth == right.NetIncomeGrowth &&
left.Periods == right.Periods,
(ManualSalesRecord left, ManualSalesRecord right) =>
left.Quarters.SequenceEqual(right.Quarters),
(ManualOperatingProfitRecord left, ManualOperatingProfitRecord right) =>
left.Quarters.SequenceEqual(right.Quarters),
_ => false
};
if (!matches)
{
throw Verification("GraphE create readback did not match.");
}
}
private static async Task EnsureManualAbsentAsync(
IManualFinancialScreenService service,
ManualFinancialIdentity identity,
CancellationToken cancellationToken)
{
try
{
_ = await service.GetAsync(identity, cancellationToken).ConfigureAwait(false);
}
catch (ManualFinancialNotFoundException)
{
return;
}
throw Verification("GraphE record still exists.");
}
private static async Task EnsureNamedPlaylistAbsentAsync(
INamedPlaylistPersistenceService service,
string programCode,
CancellationToken cancellationToken)
{
try
{
_ = await service.LoadAsync(programCode, cancellationToken).ConfigureAwait(false);
}
catch (NamedPlaylistNotFoundException)
{
return;
}
throw Verification("Named playlist still exists.");
}
private static async Task EnsureThemeAbsentAsync(
IThemeSelectionService selection,
string title,
CancellationToken cancellationToken)
{
var search = await selection.SearchAsync(
title,
ThemeSession.PreMarket,
LegacyThemeSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated || search.Items.Any(item =>
string.Equals(
item.Identity.ThemeTitle,
title,
StringComparison.Ordinal)))
{
throw Collision("Theme absence was not proven.");
}
}
private static async Task EnsureExpertAbsentAsync(
IExpertSelectionService selection,
string name,
CancellationToken cancellationToken)
{
var search = await selection.SearchAsync(
name,
LegacyExpertSelectionService.MaximumResults,
cancellationToken).ConfigureAwait(false);
if (search.IsTruncated || search.Items.Any(item =>
string.Equals(
item.Identity.ExpertName,
name,
StringComparison.Ordinal)))
{
throw Collision("Expert absence was not proven.");
}
}
private static async Task IgnoreResultAsync<T>(Task<T> operation)
{
_ = await operation.ConfigureAwait(false);
}
private static string Required(string? value) =>
value ?? throw new InvalidOperationException("Smoke preflight did not produce an identity.");
private static bool IsOutcomeUnknown(Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
if (exception is ManualFinancialMutationException manual && manual.OutcomeUnknown ||
exception is NamedPlaylistMutationException named && named.OutcomeUnknown ||
exception is OperatorCatalogMutationException catalog && catalog.OutcomeUnknown)
{
return true;
}
if (exception is AggregateException aggregate &&
aggregate.InnerExceptions.Any(IsOutcomeUnknown))
{
return true;
}
return exception.InnerException is not null && IsOutcomeUnknown(exception.InnerException);
}
private static InvalidOperationException Collision(string message) => new(message);
private static InvalidOperationException Verification(string message) => new(message);
}

View File

@@ -0,0 +1,21 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal static class Program
{
private static async Task<int> Main(string[] args)
{
if (OwnedManualSmokeResidueRecovery.IsRequested(args))
{
return await OwnedManualSmokeResidueRecovery.RunAsync(args, Console.Out)
.ConfigureAwait(false);
}
return await DevelopmentDatabaseWriteSmokeApp.RunAsync(
args,
ProductionDevelopmentDatabaseSmokePlan.Create,
Console.Out)
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests")]

View File

@@ -0,0 +1,49 @@
# Development database write smoke
This executable is deliberately separate from the read-only `DbSmoke` tool. It
does not load database configuration or make a database call unless the exact
write acknowledgement is present:
```powershell
dotnet run --project tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj -- `
--i-understand-this-writes-development-database
```
Use `--configuration <path>` only when the normal LocalAppData configuration is
not the intended development configuration. Credentials, SQL, generated
identities, and database values are never printed.
The plan covers every migrated database writer:
- all four Oracle GraphE `INPUT_*` screens;
- Oracle AList/PList definition creation and item replacement;
- ThemeA KRX (Oracle) and NXT (MariaDB) creation;
- EList expert creation (Oracle).
Each scenario uses a legacy-column-safe 20-character
`MBW<UTC-date><40-bit nonce><scenario>` name,
proves that name is absent, performs each production-service mutation once,
reads the row back through the production read service, deletes it in `finally`,
and verifies absence. Numeric legacy keys cannot contain the prefix, so their
production next-code service supplies the numeric key and the conditional insert
still rejects a concurrent collision without overwriting an existing row. A
bound, read-only child-row preflight also rejects keys that have legacy orphaned
`PLAY_LIST`, `SB_ITEM`, or `RECOMMEND_LIST` rows, so cleanup cannot delete an
older row.
Both read and write retries are disabled. If a mutation outcome is unknown, the
tool stops immediately and issues no retry, cleanup mutation, or later scenario.
Its redacted output then reports that the uniquely prefixed row may require
manual inspection.
If a known-committed manual smoke row could not be parsed or deleted, reconcile
only the tool-owned current-UTC-date namespace with:
```powershell
dotnet run --project tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj -- `
--reconcile-known-owned-manual-residue `
--i-understand-this-writes-development-database
```
The recovery refuses a truncated or non-singular per-table match and never
retries a delete.

View File

@@ -0,0 +1,64 @@
#nullable enable
using MMoneyCoderSharp.Data;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MySqlConnector;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
/// <summary>
/// Emits only a provider/type classification. Messages, SQL, credentials,
/// identifiers, and values deliberately never cross this boundary.
/// </summary>
internal static class RedactedDatabaseFailureClassifier
{
internal static string Classify(Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
foreach (var current in Flatten(exception))
{
switch (current)
{
case OracleException oracle:
return "Oracle-" + oracle.Number.ToString(
System.Globalization.CultureInfo.InvariantCulture);
case MySqlException mariaDb:
return "MariaDb-" + ((int)mariaDb.ErrorCode).ToString(
System.Globalization.CultureInfo.InvariantCulture);
case DatabaseOperationException database:
return database.DataSource + "-" +
(database.ProviderErrorCode?.ToString(
System.Globalization.CultureInfo.InvariantCulture) ?? "none");
case ManualFinancialMutationRejectedException rejected:
return "ManualRejected-" + rejected.Reason;
case OperatorCatalogConflictException conflict:
return "CatalogConflict-" + conflict.Kind + "-" + conflict.CommandKind;
}
}
return exception.GetType().Name;
}
private static IEnumerable<Exception> Flatten(Exception exception)
{
var pending = new Stack<Exception>();
var visited = new HashSet<Exception>(ReferenceEqualityComparer.Instance);
pending.Push(exception);
while (pending.TryPop(out var current) && visited.Add(current))
{
yield return current;
if (current is AggregateException aggregate)
{
foreach (var inner in aggregate.InnerExceptions.Reverse())
{
pending.Push(inner);
}
}
else if (current.InnerException is not null)
{
pending.Push(current.InnerException);
}
}
}
}

View File

@@ -0,0 +1,65 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal sealed record SmokeCommandOptions(
bool ShowHelp,
bool IsWriteAcknowledged,
string? ConfigurationPath);
internal static class SmokeCommandLine
{
internal const string WriteAcknowledgement =
"--i-understand-this-writes-development-database";
internal const string ConfigurationOption = "--configuration";
internal static bool TryParse(
IReadOnlyList<string> arguments,
out SmokeCommandOptions options)
{
ArgumentNullException.ThrowIfNull(arguments);
options = new SmokeCommandOptions(false, false, null);
if (arguments.Count == 0)
{
return true;
}
if (arguments.Count == 1 &&
arguments[0] is "--help" or "-h" or "/?")
{
options = options with { ShowHelp = true };
return true;
}
var acknowledged = false;
string? configurationPath = null;
for (var index = 0; index < arguments.Count; index++)
{
var argument = arguments[index];
switch (argument)
{
case WriteAcknowledgement when !acknowledged:
acknowledged = true;
break;
case ConfigurationOption when configurationPath is null &&
index + 1 < arguments.Count:
configurationPath = arguments[++index];
if (string.IsNullOrWhiteSpace(configurationPath))
{
return false;
}
break;
default:
return false;
}
}
options = new SmokeCommandOptions(false, acknowledged, configurationPath);
return true;
}
internal static string Usage =>
"Usage: MBN_STOCK_WEBVIEW.DbWriteSmoke " + WriteAcknowledgement +
" [" + ConfigurationOption + " <database.local.json>]";
}

View File

@@ -0,0 +1,55 @@
#nullable enable
using System.Globalization;
namespace MBN_STOCK_WEBVIEW.DbWriteSmoke;
internal sealed record SmokeRunIdentity(string BaseName)
{
internal const string Prefix = "MBW";
private static readonly IReadOnlyDictionary<string, char> ScenarioCodes =
new Dictionary<string, char>(StringComparer.Ordinal)
{
["G_PIE"] = 'P',
["G_GROW"] = 'G',
["G_SELL"] = 'S',
["G_PROFIT"] = 'O',
["PLAYLIST"] = 'L',
["THEME_KRX"] = 'K',
["THEME_NXT"] = 'N',
["EXPERT"] = 'E'
};
internal static SmokeRunIdentity Create(DateTimeOffset utcNow, Guid nonce)
{
if (nonce == Guid.Empty)
{
throw new ArgumentException("A non-empty smoke nonce is required.", nameof(nonce));
}
var utc = utcNow.ToUniversalTime();
var timestamp = utc.ToString("yyMMdd", CultureInfo.InvariantCulture);
var suffix = nonce.ToString("N", CultureInfo.InvariantCulture)[..10]
.ToUpperInvariant();
return new SmokeRunIdentity(Prefix + timestamp + suffix);
}
internal string For(string suffix)
{
if (!ScenarioCodes.TryGetValue(suffix, out var scenarioCode))
{
throw new ArgumentException("The smoke identity suffix is invalid.", nameof(suffix));
}
var value = BaseName + scenarioCode;
if (value.Length != 20 ||
value.Any(static character =>
!(character is >= 'A' and <= 'Z' or >= '0' and <= '9')))
{
throw new InvalidOperationException("The smoke identity is outside its closed boundary.");
}
return value;
}
}