Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ To ensure high extensibility and dependency isolation, this project adopts a mul
* **SQL Dialect Generator**: Highly secure SQL AST construction that dynamically translates into native parameterized SQL queries/commands for Sqlite, Postgres, and MySQL, inherently preventing SQL injection.
* **Unified Runtime Context**: A centralized `UserContext` runtime that natively supports chained storage propagation and dependency injection, ensuring environment variables seamlessly pass through various services alongside the request.
* **Governed Mutation Policy**: Customer policy reviews one immutable whole-graph mutation plan before the first provider write. Exact policy approval, warning codes, operation summaries, and policy identity are retained in application-audit evidence; policy denial is fail-closed while warning delivery is fail-open.
* **Portable Business ID Encoding**: Core scope/key types and the runtime `daily-permuted-v1` encoder execute the canonical cross-language golden vectors without exposing the internal sequence.
* **Governed Business ID Lifecycle**: Core model contracts, a context-owned profile/key/service boundary, retry-safe assignment, an in-memory allocator, and explicit-schema durable SQLite allocation extend the portable `daily-permuted-v1` encoder without exposing its internal sequence.
* **ASP.NET Core Web Endpoint**: Integrates instantly with `Microsoft.AspNetCore.Builder`, exposing underlying abstract data services as RESTful endpoints with just a few lines of code.
* **Redis Cache Decorator**: The `RedisDataServiceDecorator` enables transparent, underlying distributed caching for data interactions out-of-the-box.

Expand Down Expand Up @@ -110,9 +110,10 @@ var key = new BusinessIdEncodingKey(1, keyFromSecretManager);
var code = BusinessIdPermutationV1.Encode(0, scope, key);
```

This is the portable pure encoder, not a complete distributed generator.
Durable allocation, command-retry reuse, and typed external lookup remain
separate lifecycle capabilities. Keys belong to an application-owned secret
The retained [`examples/business-id`](examples/business-id) flow proves
explicit schema installation, durable concurrent allocation infrastructure and
command-retry reuse. Generated strongly typed fields and external lookup remain
a separate generator capability. Keys belong to an application-owned secret
provider and never to KSML or generated source.

## Quick Start
Expand Down
41 changes: 41 additions & 0 deletions examples/business-id/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.Data.Sqlite;
using TeaQL.Core;
using TeaQL.Provider.Sqlite;
using TeaQL.Runtime;
using TeaQL.Sql;

var path = Path.Combine(Path.GetTempPath(), $"teaql-business-id-{Guid.NewGuid():N}.db");
try
{
await using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
await connection.OpenAsync();
var allocator = new OptimisticBusinessIdAllocator(
new SqliteTransport(connection), new SqliteDialect());
await allocator.EnsureSchemaAsync();
var context = new UserContext()
.WithBusinessClock(new FixedBusinessClock(
new DateTimeOffset(2026, 10, 1, 8, 30, 0, TimeSpan.Zero)))
.WithBusinessIdKeyProvider(new StaticBusinessIdKeyProvider(
new BusinessIdEncodingKey(1,
Enumerable.Range(0, 32).Select(value => (byte)value).ToArray())))
.WithBusinessIdProfileFactory(new DefaultBusinessIdProfileFactory())
.WithBusinessIdService(new DefaultBusinessIdService(allocator));
var slot = new OrderNumberSlot();
var definition = BusinessIdDefinition.DailyPermuted(
"order_number", "ORD", "order_number");
var first = await context.EnsureBusinessIdAsync(
definition, "commerce", "commerce_order", slot);
var retry = await context.EnsureBusinessIdAsync(
definition, "commerce", "commerce_order", slot);
if (first != retry || !slot.CurrentValue!.StartsWith("ORD-20261001-"))
throw new InvalidOperationException("Business ID retry was not idempotent");
Console.WriteLine("PASS .NET governed Business ID lifecycle example {0}", first.Value);
}
finally { if (File.Exists(path)) File.Delete(path); }

sealed class OrderNumberSlot : IBusinessIdSlot
{
public string? CurrentValue { get; private set; }
public bool IsNewAggregate => true;
public void AssignCanonicalValue(string value) => CurrentValue = value;
}
9 changes: 9 additions & 0 deletions examples/business-id/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Governed Business ID example

This focused example proves explicit SQLite schema installation, a
context-owned business date and key provider, durable allocation, and
idempotent reuse of an already assigned Business ID.

```bash
dotnet run --project examples/business-id/business-id.csproj
```
16 changes: 16 additions & 0 deletions examples/business-id/business-id.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/TeaQL.Runtime/TeaQL.Runtime.csproj" />
<ProjectReference Include="../../src/TeaQL.Sql/TeaQL.Sql.csproj" />
<ProjectReference Include="../../src/TeaQL.Provider.Sqlite/TeaQL.Provider.Sqlite.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
</ItemGroup>
</Project>
3 changes: 2 additions & 1 deletion scripts/verify-examples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -euo pipefail

repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
expected=(conformance mutation-policy order-management runtime-logging school-management security-foundations task_board)
expected=(business-id conformance mutation-policy order-management runtime-logging school-management security-foundations task_board)
mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\n' | sort)
if [[ "${actual[*]}" != "${expected[*]}" ]]; then
echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2
Expand All @@ -20,6 +20,7 @@ runtime_source_root="${TeaQLRuntimeSourceRoot:-$repo}"
order_management_tmp="$(mktemp -d)"
trap 'rm -rf "$order_management_tmp"' EXIT
dotnet run --property:TeaQLRuntimeSourceRoot="$runtime_source_root" --project "$repo/examples/conformance/runtime-example-conformance-service-console.csproj"
dotnet run --property:TeaQLRuntimeSourceRoot="$runtime_source_root" --project "$repo/examples/business-id/business-id.csproj"
dotnet run --property:TeaQLRuntimeSourceRoot="$runtime_source_root" --project "$repo/examples/runtime-logging/runtime-logging.csproj"
dotnet run --property:TeaQLRuntimeSourceRoot="$runtime_source_root" --project "$repo/examples/mutation-policy/mutation-policy.csproj"
dotnet run --property:TeaQLRuntimeSourceRoot="$runtime_source_root" --project "$repo/examples/school-management/school-management-service-lib.csproj"
Expand Down
131 changes: 131 additions & 0 deletions src/TeaQL.Core/BusinessId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ namespace TeaQL.Core;

public enum BusinessIdErrorCode
{
BusinessIdProfileNotFound,
BusinessIdDefinitionInvalid,
BusinessIdRangeExhausted,
BusinessIdAllocationRetryExhausted,
BusinessIdFormatInvalid,
BusinessIdDuplicate,
BusinessIdImmutable,
BusinessIdKeyNotFound,
BusinessIdEncodingFailed
}

Expand Down Expand Up @@ -35,6 +41,13 @@ private static string Require(string value, string name)
$"{name} must not be blank");
return value;
}

public string CanonicalKey => string.Join("|", new[]
{
Escape(DomainRootKey), Escape(AggregateType), Escape(Namespace), Escape(PeriodKey)
});

private static string Escape(string value) => value.Replace("%", "%25").Replace("|", "%7C");
}

/// <summary>Versioned 256-bit key material supplied by an application-owned provider.</summary>
Expand All @@ -57,3 +70,121 @@ public BusinessIdEncodingKey(uint version, byte[] bytes)

public byte[] Bytes => _bytes.ToArray();
}

public sealed record BusinessIdDefinition
{
public const string DefaultProfile = "daily-permuted-v1";
public const string DefaultDateFormat = "yyyyMMdd";
public const int DefaultDigits = 6;

public string FieldName { get; }
public string Profile { get; }
public string Prefix { get; }
public string DateFormat { get; }
public string Reset { get; }
public int Digits { get; }
public string Separator { get; }
public string Namespace { get; }
public int PolicyVersion { get; }

public BusinessIdDefinition(string fieldName, string profile, string prefix,
string dateFormat, string reset, int digits, string separator,
string @namespace, int policyVersion)
{
FieldName = Require(fieldName, nameof(fieldName));
Profile = Require(profile, nameof(profile));
Prefix = Require(prefix, nameof(prefix));
DateFormat = Require(dateFormat, nameof(dateFormat));
Reset = Require(reset, nameof(reset));
Separator = Require(separator, nameof(separator));
Namespace = Require(@namespace, nameof(@namespace));
if (profile == DefaultProfile && digits != DefaultDigits)
throw Invalid("daily-permuted-v1 requires exactly 6 digits");
if (digits is < 1 or > 18) throw Invalid("digits must be between 1 and 18");
if (policyVersion < 1) throw Invalid("policyVersion must be positive");
Digits = digits;
PolicyVersion = policyVersion;
}

public static BusinessIdDefinition DailyPermuted(string fieldName, string prefix,
string @namespace) => new(fieldName, DefaultProfile, prefix,
DefaultDateFormat, "daily", DefaultDigits, "-", @namespace, 1);

public ulong MaximumSequence
{
get
{
if (Profile == DefaultProfile) return 2_176_782_335UL;
ulong value = 1;
for (var index = 0; index < Digits; index++) value = checked(value * 10);
return value - 1;
}
}

private static string Require(string value, string name)
{
if (string.IsNullOrWhiteSpace(value)) throw Invalid($"{name} must not be blank");
return value;
}

private static BusinessIdException Invalid(string message) =>
new(BusinessIdErrorCode.BusinessIdDefinitionInvalid, message);
}

public sealed record BusinessIdGenerationRequest(BusinessIdDefinition Definition,
string DomainRootKey, string AggregateType, DateOnly BusinessDate);

public sealed record BusinessIdPlan
{
public BusinessIdDefinition Definition { get; }
public BusinessIdScope Scope { get; }
public DateOnly BusinessDate { get; }
public string DateText { get; }
public ulong InitialSequence { get; }
public ulong MaximumSequence { get; }

public BusinessIdPlan(BusinessIdDefinition definition, BusinessIdScope scope,
DateOnly businessDate, string dateText, ulong initialSequence, ulong maximumSequence)
{
if (maximumSequence < initialSequence)
throw new BusinessIdException(BusinessIdErrorCode.BusinessIdDefinitionInvalid,
"Business ID allocation range must satisfy initialSequence <= maximumSequence");
Definition = definition;
Scope = scope;
BusinessDate = businessDate;
DateText = dateText;
InitialSequence = initialSequence;
MaximumSequence = maximumSequence;
}
}

public sealed record BusinessIdAllocation(BusinessIdScope Scope, ulong Sequence);

public sealed record BusinessIdValue
{
public string Value { get; }
public string Profile { get; }
public int PolicyVersion { get; }

public BusinessIdValue(string value, string profile, int policyVersion)
{
if (string.IsNullOrWhiteSpace(value))
throw new BusinessIdException(BusinessIdErrorCode.BusinessIdFormatInvalid,
"Business ID value must not be blank");
Value = value;
Profile = profile;
PolicyVersion = policyVersion;
}
}

public interface IBusinessIdSlot
{
string? CurrentValue { get; }
bool IsNewAggregate { get; }
void AssignCanonicalValue(string value);
}

public interface IBusinessIdAllocator
{
Task<BusinessIdAllocation> AllocateAsync(BusinessIdPlan plan);
}
91 changes: 91 additions & 0 deletions src/TeaQL.Provider.Sqlite.Tests/BusinessIdAllocatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using Microsoft.Data.Sqlite;
using TeaQL.Core;
using TeaQL.Sql;
using Xunit;

namespace TeaQL.Provider.Sqlite.Tests;

public class BusinessIdAllocatorTests
{
private static BusinessIdPlan Plan(string root, string @namespace,
ulong maximum = 100) => new(
BusinessIdDefinition.DailyPermuted(
"order_number", "ORD", "order_number"),
new BusinessIdScope(root, "commerce_order", @namespace, "20261001"),
new DateOnly(2026, 10, 1), "20261001", 0, maximum);

private static async Task<SqliteConnection> OpenAsync(string path)
{
var connection = new SqliteConnection(
$"Data Source={path};Default Timeout=5;Pooling=False");
await connection.OpenAsync();
return connection;
}

[Fact]
public async Task ConstructionDoesNotCreateSchema()
{
var path = Path.Combine(Path.GetTempPath(), $"teaql-business-explicit-{Guid.NewGuid():N}.db");
try
{
await using var connection = await OpenAsync(path);
var allocator = new OptimisticBusinessIdAllocator(
new SqliteTransport(connection), new SqliteDialect());
var error = await Assert.ThrowsAsync<SqlExecutorException>(() =>
allocator.AllocateAsync(Plan("root", "order_number")));
Assert.Contains("explicit EnsureSchemaAsync", error.Message);
}
finally { if (File.Exists(path)) File.Delete(path); }
}

[Fact]
public async Task AllocationIsSharedConcurrentRestartSafeAndScopeIsolated()
{
var path = Path.Combine(Path.GetTempPath(), $"teaql-business-shared-{Guid.NewGuid():N}.db");
try
{
await using var firstConnection = await OpenAsync(path);
await using var secondConnection = await OpenAsync(path);
var first = new OptimisticBusinessIdAllocator(
new SqliteTransport(firstConnection), new SqliteDialect());
var second = new OptimisticBusinessIdAllocator(
new SqliteTransport(secondConnection), new SqliteDialect());
await first.EnsureSchemaAsync();
var plan = Plan("root", "order_number");

var tasks = Enumerable.Range(0, 40)
.Select(index => (index % 2 == 0 ? first : second).AllocateAsync(plan));
var allocated = (await Task.WhenAll(tasks))
.Select(value => value.Sequence).OrderBy(value => value).ToArray();
Assert.Equal(Enumerable.Range(0, 40).Select(value => (ulong)value), allocated);

await using var restartedConnection = await OpenAsync(path);
var restarted = new OptimisticBusinessIdAllocator(
new SqliteTransport(restartedConnection), new SqliteDialect());
Assert.Equal(40ul, (await restarted.AllocateAsync(plan)).Sequence);
Assert.Equal(0ul, (await restarted.AllocateAsync(
Plan("other-root", "order_number"))).Sequence);
}
finally { if (File.Exists(path)) File.Delete(path); }
}

[Fact]
public async Task CapacityExhaustionIsClassified()
{
var path = Path.Combine(Path.GetTempPath(), $"teaql-business-capacity-{Guid.NewGuid():N}.db");
try
{
await using var connection = await OpenAsync(path);
var allocator = new OptimisticBusinessIdAllocator(
new SqliteTransport(connection), new SqliteDialect());
await allocator.EnsureSchemaAsync();
var plan = Plan("root", "tiny", 1);
Assert.Equal(0ul, (await allocator.AllocateAsync(plan)).Sequence);
Assert.Equal(1ul, (await allocator.AllocateAsync(plan)).Sequence);
var error = await Assert.ThrowsAsync<BusinessIdException>(() =>
allocator.AllocateAsync(plan));
Assert.Equal(BusinessIdErrorCode.BusinessIdRangeExhausted, error.Code);
}
finally { if (File.Exists(path)) File.Delete(path); }
}
}
3 changes: 3 additions & 0 deletions src/TeaQL.Provider.Sqlite/SqliteDialect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public override string Placeholder(int index)
return $"@p{index - 1}";
}

public override string[] SchemaSetupSqls() =>
[OptimisticBusinessIdAllocator.SqliteSchemaSql];

public override string SchemaTypeSql(DataType dataType, PropertyDescriptor property)
{
return dataType switch
Expand Down
Loading
Loading