Add 12 new sprint files (Integrations, Graph, JobEngine, FE, Router, AdvisoryAI), archive completed scheduler UI sprint, update module architecture docs (router, graph, jobengine, web, integrations), and add Gitea entrypoint script for local dev. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
936 lines
24 KiB
Markdown
936 lines
24 KiB
Markdown
# Database Coding Rules
|
|
|
|
**Version:** 1.1.1
|
|
**Status:** APPROVED
|
|
**Last Updated:** 2026-04-05
|
|
|
|
---
|
|
|
|
## Purpose
|
|
|
|
This document defines mandatory rules and guidelines for all database-related code in StellaOps. These rules ensure consistency, maintainability, determinism, and security across all modules.
|
|
|
|
**Compliance is mandatory.** Deviations require explicit approval documented in the relevant sprint file.
|
|
|
|
---
|
|
|
|
## 1. Repository Pattern Rules
|
|
|
|
### 1.1 Interface Location
|
|
|
|
**RULE:** Repository interfaces MUST be defined in the Core/Domain layer, NOT in the storage layer.
|
|
|
|
```
|
|
✓ CORRECT:
|
|
src/Scheduler/__Libraries/StellaOps.Scheduler.Core/Repositories/IScheduleRepository.cs
|
|
|
|
✗ INCORRECT:
|
|
src/Scheduler/__Libraries/StellaOps.Scheduler.Storage.Postgres/IScheduleRepository.cs
|
|
```
|
|
|
|
### 1.2 Implementation Naming
|
|
|
|
**RULE:** Repository implementations MUST be prefixed with the storage technology.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
public sealed class PostgresScheduleRepository : IScheduleRepository
|
|
public sealed class MongoScheduleRepository : IScheduleRepository
|
|
|
|
// ✗ INCORRECT
|
|
public sealed class ScheduleRepository : IScheduleRepository
|
|
```
|
|
|
|
### 1.3 Dependency Injection
|
|
|
|
**RULE:** PostgreSQL repositories MUST be registered as `Scoped`. MongoDB repositories MAY be `Singleton`.
|
|
|
|
```csharp
|
|
// PostgreSQL - always scoped (connection per request)
|
|
services.AddScoped<IScheduleRepository, PostgresScheduleRepository>();
|
|
|
|
// MongoDB - singleton is acceptable (stateless)
|
|
services.AddSingleton<IScheduleRepository, MongoScheduleRepository>();
|
|
```
|
|
|
|
### 1.4 No Direct SQL in Services
|
|
|
|
**RULE:** Business logic services MUST NOT contain raw SQL. All database access MUST go through repository interfaces.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
public class ScheduleService
|
|
{
|
|
private readonly IScheduleRepository _repository;
|
|
|
|
public Task<Schedule?> GetAsync(string id)
|
|
=> _repository.GetAsync(id);
|
|
}
|
|
|
|
// ✗ INCORRECT
|
|
public class ScheduleService
|
|
{
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
|
|
public async Task<Schedule?> GetAsync(string id)
|
|
{
|
|
await using var conn = await _dataSource.OpenConnectionAsync();
|
|
// Direct SQL here - FORBIDDEN
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Connection Management Rules
|
|
|
|
### 2.1 DataSource Pattern
|
|
|
|
**RULE:** Every module MUST have its own DataSource class that configures tenant context.
|
|
|
|
```csharp
|
|
public sealed class SchedulerDataSource : IAsyncDisposable
|
|
{
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
|
|
public async Task<NpgsqlConnection> OpenConnectionAsync(
|
|
string tenantId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await ConfigureSessionAsync(connection, tenantId, cancellationToken);
|
|
return connection;
|
|
}
|
|
|
|
private static async Task ConfigureSessionAsync(
|
|
NpgsqlConnection connection,
|
|
string tenantId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// MANDATORY: Set tenant context and UTC timezone
|
|
await using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = $"""
|
|
SET app.tenant_id = '{tenantId}';
|
|
SET timezone = 'UTC';
|
|
SET statement_timeout = '30s';
|
|
""";
|
|
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.1.1 Runtime Data Source Attribution
|
|
|
|
**RULE:** Every runtime `NpgsqlDataSource` MUST set a stable PostgreSQL `application_name`.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
|
|
{
|
|
ApplicationName = "stellaops-policy"
|
|
};
|
|
var dataSource = new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build();
|
|
|
|
// ✓ CORRECT - shared infrastructure path
|
|
var connectionString = PostgresConnectionStringPolicy.Build(options, "stellaops-policy");
|
|
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
|
|
|
// ✗ INCORRECT - anonymous session attribution
|
|
var dataSource = NpgsqlDataSource.Create(options.ConnectionString);
|
|
```
|
|
|
|
**RATIONALE:** Anonymous PostgreSQL sessions make runtime triage and CPU/churn attribution materially harder. `application_name` is mandatory for steady-state service code.
|
|
|
|
### 2.1.2 Runtime Data Source Construction
|
|
|
|
**RULE:** Runtime services MUST reuse a singleton/module-scoped `NpgsqlDataSource` or a module DataSource wrapper. Ad hoc `NpgsqlDataSource.Create(...)` is forbidden in steady-state service code.
|
|
|
|
Allowed exceptions:
|
|
- tests and fixtures
|
|
- migration runners and schema bootstrap hosts
|
|
- CLI/admin/setup commands
|
|
- one-shot diagnostics explicitly documented in the sprint
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
services.AddSingleton<MyModuleDataSource>();
|
|
services.AddScoped<IMyRepository, PostgresMyRepository>();
|
|
|
|
// ✗ INCORRECT
|
|
public async Task<Item?> GetAsync(CancellationToken ct)
|
|
{
|
|
await using var dataSource = NpgsqlDataSource.Create(_connectionString);
|
|
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
|
...
|
|
}
|
|
```
|
|
|
|
**RULE:** Raw `new NpgsqlConnection(...)` is forbidden in steady-state runtime code unless the call site is an explicit allowlisted exception (CLI/setup, migrations, diagnostics, or a sprint-documented blocker).
|
|
|
|
**RULE:** When a module cannot yet move off raw `NpgsqlConnection`, its connection string MUST still flow through a stable `ApplicationName`, and the exception MUST be called out in sprint Decisions & Risks.
|
|
|
|
### 2.2 Connection Disposal
|
|
|
|
**RULE:** All NpgsqlConnection instances MUST be disposed via `await using`.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, ct);
|
|
|
|
// ✗ INCORRECT
|
|
var connection = await _dataSource.OpenConnectionAsync(tenantId, ct);
|
|
// Missing disposal
|
|
```
|
|
|
|
### 2.3 Command Disposal
|
|
|
|
**RULE:** All NpgsqlCommand instances MUST be disposed via `await using`.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
await using var cmd = connection.CreateCommand();
|
|
|
|
// ✗ INCORRECT
|
|
var cmd = connection.CreateCommand();
|
|
```
|
|
|
|
### 2.4 Reader Disposal
|
|
|
|
**RULE:** All NpgsqlDataReader instances MUST be disposed via `await using`.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
|
|
// ✗ INCORRECT
|
|
var reader = await cmd.ExecuteReaderAsync(ct);
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Tenant Isolation Rules
|
|
|
|
### 3.1 Tenant ID Required
|
|
|
|
**RULE:** Every tenant-scoped repository method MUST require `tenantId` as the first parameter.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
Task<Schedule?> GetAsync(string tenantId, string scheduleId, CancellationToken ct);
|
|
Task<IReadOnlyList<Schedule>> ListAsync(string tenantId, QueryOptions? options, CancellationToken ct);
|
|
|
|
// ✗ INCORRECT
|
|
Task<Schedule?> GetAsync(string scheduleId, CancellationToken ct);
|
|
```
|
|
|
|
### 3.2 Tenant Filtering
|
|
|
|
**RULE:** All queries MUST include `tenant_id` in the WHERE clause for tenant-scoped tables.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.CommandText = """
|
|
SELECT * FROM scheduler.schedules
|
|
WHERE tenant_id = @tenant_id AND id = @id
|
|
""";
|
|
|
|
// ✗ INCORRECT - Missing tenant filter
|
|
cmd.CommandText = """
|
|
SELECT * FROM scheduler.schedules
|
|
WHERE id = @id
|
|
""";
|
|
```
|
|
|
|
### 3.3 Session Context Verification
|
|
|
|
**RULE:** DataSource MUST set `app.tenant_id` on every connection before executing any queries.
|
|
|
|
```csharp
|
|
// ✓ CORRECT - Connection opened via DataSource sets tenant context
|
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, ct);
|
|
|
|
// ✗ INCORRECT - Direct connection without tenant context
|
|
await using var connection = await _rawDataSource.OpenConnectionAsync(ct);
|
|
```
|
|
|
|
---
|
|
|
|
## 4. SQL Writing Rules
|
|
|
|
### 4.1 Parameterized Queries Only
|
|
|
|
**RULE:** All user-provided values MUST be passed as parameters. String interpolation is FORBIDDEN for values.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.CommandText = "SELECT * FROM users WHERE id = @id";
|
|
cmd.Parameters.AddWithValue("id", userId);
|
|
|
|
// ✗ INCORRECT - SQL INJECTION VULNERABILITY
|
|
cmd.CommandText = $"SELECT * FROM users WHERE id = '{userId}'";
|
|
```
|
|
|
|
### 4.2 SQL String Constants
|
|
|
|
**RULE:** SQL strings MUST be defined as `const` or `static readonly` fields, or as raw string literals in methods.
|
|
|
|
```csharp
|
|
// ✓ CORRECT - Raw string literal
|
|
cmd.CommandText = """
|
|
SELECT id, name, created_at
|
|
FROM scheduler.schedules
|
|
WHERE tenant_id = @tenant_id
|
|
ORDER BY created_at DESC
|
|
""";
|
|
|
|
// ✓ CORRECT - Constant
|
|
private const string SelectScheduleSql = """
|
|
SELECT id, name, created_at
|
|
FROM scheduler.schedules
|
|
WHERE tenant_id = @tenant_id
|
|
""";
|
|
|
|
// ✗ INCORRECT - Dynamic string building without reason
|
|
cmd.CommandText = "SELECT " + columns + " FROM " + table;
|
|
```
|
|
|
|
### 4.3 Schema Qualification
|
|
|
|
**RULE:** All table references MUST include the schema name.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.CommandText = "SELECT * FROM scheduler.schedules";
|
|
|
|
// ✗ INCORRECT - Missing schema
|
|
cmd.CommandText = "SELECT * FROM schedules";
|
|
```
|
|
|
|
### 4.4 Column Listing
|
|
|
|
**RULE:** SELECT statements MUST list columns explicitly. `SELECT *` is FORBIDDEN in production code.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.CommandText = """
|
|
SELECT id, tenant_id, name, enabled, created_at
|
|
FROM scheduler.schedules
|
|
""";
|
|
|
|
// ✗ INCORRECT
|
|
cmd.CommandText = "SELECT * FROM scheduler.schedules";
|
|
```
|
|
|
|
### 4.5 Consistent Casing
|
|
|
|
**RULE:** SQL keywords MUST be lowercase for consistency with PostgreSQL conventions.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.CommandText = """
|
|
select id, name
|
|
from scheduler.schedules
|
|
where tenant_id = @tenant_id
|
|
order by created_at desc
|
|
""";
|
|
|
|
// ✗ INCORRECT - Mixed casing
|
|
cmd.CommandText = """
|
|
SELECT id, name
|
|
FROM scheduler.schedules
|
|
WHERE tenant_id = @tenant_id
|
|
""";
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Data Type Rules
|
|
|
|
### 5.1 UUID Handling
|
|
|
|
**RULE:** UUIDs MUST be passed as `Guid` type to Npgsql, NOT as strings.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.Parameters.AddWithValue("id", Guid.Parse(scheduleId));
|
|
|
|
// ✗ INCORRECT
|
|
cmd.Parameters.AddWithValue("id", scheduleId); // String
|
|
```
|
|
|
|
### 5.2 Timestamp Handling
|
|
|
|
**RULE:** All timestamps MUST be `DateTimeOffset` or `DateTime` with `Kind = Utc`.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.Parameters.AddWithValue("created_at", DateTimeOffset.UtcNow);
|
|
cmd.Parameters.AddWithValue("created_at", DateTime.UtcNow);
|
|
|
|
// ✗ INCORRECT - Local time
|
|
cmd.Parameters.AddWithValue("created_at", DateTime.Now);
|
|
```
|
|
|
|
### 5.3 JSONB Serialization
|
|
|
|
**RULE:** JSONB columns MUST be serialized using `System.Text.Json.JsonSerializer` with consistent options.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
var json = JsonSerializer.Serialize(obj, JsonSerializerOptions.Default);
|
|
cmd.Parameters.AddWithValue("config", json);
|
|
|
|
// ✗ INCORRECT - Newtonsoft or inconsistent serialization
|
|
var json = Newtonsoft.Json.JsonConvert.SerializeObject(obj);
|
|
```
|
|
|
|
### 5.3.1 Generated Columns for JSONB Hot Keys
|
|
|
|
**RULE:** Frequently-queried JSONB fields (>10% of queries) SHOULD be extracted as generated columns.
|
|
|
|
**When to use generated columns:**
|
|
- Field is used in WHERE clauses frequently
|
|
- Field is used in JOIN conditions
|
|
- Field is used in GROUP BY or ORDER BY
|
|
- Query planner needs cardinality statistics
|
|
|
|
```sql
|
|
-- ✓ CORRECT: Generated column for hot JSONB field
|
|
ALTER TABLE scheduler.runs
|
|
ADD COLUMN finding_count INT GENERATED ALWAYS AS ((stats->>'findingCount')::int) STORED;
|
|
|
|
CREATE INDEX idx_runs_finding_count ON scheduler.runs(tenant_id, finding_count);
|
|
```
|
|
|
|
**RULE:** Generated column names MUST follow snake_case convention matching the JSON path.
|
|
|
|
```sql
|
|
-- ✓ CORRECT naming
|
|
doc->>'bomFormat' → bom_format
|
|
stats->>'findingCount' → finding_count
|
|
raw->>'schemaVersion' → schema_version
|
|
|
|
-- ✗ INCORRECT naming
|
|
doc->>'bomFormat' → bomFormat, format, bf
|
|
```
|
|
|
|
**RULE:** Generated columns MUST be added with concurrent index creation in production.
|
|
|
|
```sql
|
|
-- ✓ CORRECT: Non-blocking migration
|
|
ALTER TABLE scheduler.runs ADD COLUMN finding_count INT GENERATED ALWAYS AS (...) STORED;
|
|
CREATE INDEX CONCURRENTLY idx_runs_finding_count ON scheduler.runs(finding_count);
|
|
ANALYZE scheduler.runs;
|
|
|
|
-- ✗ INCORRECT: Blocking migration
|
|
CREATE INDEX idx_runs_finding_count ON scheduler.runs(finding_count); -- Blocks table
|
|
```
|
|
|
|
**Reference:** See `SPECIFICATION.md` Section 6.4 for detailed guidelines.
|
|
|
|
### 5.4 Null Handling
|
|
|
|
**RULE:** Nullable values MUST use `DBNull.Value` when null.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.Parameters.AddWithValue("description", (object?)schedule.Description ?? DBNull.Value);
|
|
|
|
// ✗ INCORRECT - Will fail or behave unexpectedly
|
|
cmd.Parameters.AddWithValue("description", schedule.Description); // If null
|
|
```
|
|
|
|
### 5.5 Array Handling
|
|
|
|
**RULE:** PostgreSQL arrays MUST be passed as .NET arrays with explicit type.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.Parameters.AddWithValue("tags", schedule.Tags.ToArray());
|
|
|
|
// ✗ INCORRECT - List won't map correctly
|
|
cmd.Parameters.AddWithValue("tags", schedule.Tags);
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Transaction Rules
|
|
|
|
### 6.1 Explicit Transactions
|
|
|
|
**RULE:** Operations affecting multiple tables MUST use explicit transactions.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
try
|
|
{
|
|
// Multiple operations
|
|
await cmd1.ExecuteNonQueryAsync(ct);
|
|
await cmd2.ExecuteNonQueryAsync(ct);
|
|
await transaction.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await transaction.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
```
|
|
|
|
### 6.2 Transaction Isolation
|
|
|
|
**RULE:** Default isolation level is `ReadCommitted`. Stricter levels MUST be documented.
|
|
|
|
```csharp
|
|
// ✓ CORRECT - Default
|
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
|
|
// ✓ CORRECT - Explicit stricter level with documentation
|
|
// Using Serializable for financial consistency requirement
|
|
await using var transaction = await connection.BeginTransactionAsync(
|
|
IsolationLevel.Serializable, ct);
|
|
```
|
|
|
|
### 6.3 No Nested Transactions
|
|
|
|
**RULE:** Nested transactions are NOT supported. Use savepoints if needed.
|
|
|
|
```csharp
|
|
// ✗ INCORRECT - Nested transaction
|
|
await using var tx1 = await connection.BeginTransactionAsync(ct);
|
|
await using var tx2 = await connection.BeginTransactionAsync(ct); // FAILS
|
|
|
|
// ✓ CORRECT - Savepoint for partial rollback
|
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
await transaction.SaveAsync("savepoint1", ct);
|
|
// ... operations ...
|
|
await transaction.RollbackAsync("savepoint1", ct); // Partial rollback
|
|
await transaction.CommitAsync(ct);
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Error Handling Rules
|
|
|
|
### 7.1 PostgreSQL Exception Handling
|
|
|
|
**RULE:** Catch `PostgresException` for database-specific errors, not generic exceptions.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
try
|
|
{
|
|
await cmd.ExecuteNonQueryAsync(ct);
|
|
}
|
|
catch (PostgresException ex) when (ex.SqlState == "23505") // Unique violation
|
|
{
|
|
throw new DuplicateEntityException($"Entity already exists: {ex.ConstraintName}");
|
|
}
|
|
|
|
// ✗ INCORRECT - Too broad
|
|
catch (Exception ex)
|
|
{
|
|
// Can't distinguish database errors from other errors
|
|
}
|
|
```
|
|
|
|
### 7.2 Constraint Violation Handling
|
|
|
|
**RULE:** Unique constraint violations MUST be translated to domain exceptions.
|
|
|
|
| SQL State | Meaning | Domain Exception |
|
|
|-----------|---------|------------------|
|
|
| `23505` | Unique violation | `DuplicateEntityException` |
|
|
| `23503` | Foreign key violation | `ReferenceNotFoundException` |
|
|
| `23502` | Not null violation | `ValidationException` |
|
|
| `23514` | Check constraint | `ValidationException` |
|
|
|
|
### 7.3 Timeout Handling
|
|
|
|
**RULE:** Query timeouts MUST be caught and logged with context.
|
|
|
|
```csharp
|
|
try
|
|
{
|
|
await cmd.ExecuteNonQueryAsync(ct);
|
|
}
|
|
catch (NpgsqlException ex) when (ex.InnerException is TimeoutException)
|
|
{
|
|
_logger.LogWarning(ex, "Query timeout for schedule {ScheduleId}", scheduleId);
|
|
throw new QueryTimeoutException("Database query timed out", ex);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Pagination Rules
|
|
|
|
### 8.1 Keyset Pagination
|
|
|
|
**RULE:** Use keyset pagination, NOT offset pagination for large result sets.
|
|
|
|
```csharp
|
|
// ✓ CORRECT - Keyset pagination
|
|
cmd.CommandText = """
|
|
select id, name, created_at
|
|
from scheduler.schedules
|
|
where tenant_id = @tenant_id
|
|
and (created_at, id) < (@cursor_created_at, @cursor_id)
|
|
order by created_at desc, id desc
|
|
limit @page_size
|
|
""";
|
|
|
|
// ✗ INCORRECT - Offset pagination (slow for large offsets)
|
|
cmd.CommandText = """
|
|
select id, name, created_at
|
|
from scheduler.schedules
|
|
where tenant_id = @tenant_id
|
|
order by created_at desc
|
|
limit @page_size offset @offset
|
|
""";
|
|
```
|
|
|
|
### 8.2 Default Page Size
|
|
|
|
**RULE:** Default page size MUST be 50. Maximum page size MUST be 1000.
|
|
|
|
```csharp
|
|
public class QueryOptions
|
|
{
|
|
public int PageSize { get; init; } = 50;
|
|
|
|
public int GetValidatedPageSize()
|
|
=> Math.Clamp(PageSize, 1, 1000);
|
|
}
|
|
```
|
|
|
|
### 8.3 Continuation Tokens
|
|
|
|
**RULE:** Pagination cursors MUST be opaque, encoded tokens containing sort key values.
|
|
|
|
```csharp
|
|
public record PaginationCursor(DateTimeOffset CreatedAt, Guid Id)
|
|
{
|
|
public string Encode()
|
|
=> Convert.ToBase64String(
|
|
JsonSerializer.SerializeToUtf8Bytes(this));
|
|
|
|
public static PaginationCursor? Decode(string? token)
|
|
=> string.IsNullOrEmpty(token)
|
|
? null
|
|
: JsonSerializer.Deserialize<PaginationCursor>(
|
|
Convert.FromBase64String(token));
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Ordering Rules
|
|
|
|
### 9.1 Deterministic Ordering
|
|
|
|
**RULE:** All queries returning multiple rows MUST have an ORDER BY clause that produces deterministic results.
|
|
|
|
```csharp
|
|
// ✓ CORRECT - Deterministic (includes unique column)
|
|
cmd.CommandText = """
|
|
select * from scheduler.runs
|
|
order by created_at desc, id asc
|
|
""";
|
|
|
|
// ✗ INCORRECT - Non-deterministic (created_at may have ties)
|
|
cmd.CommandText = """
|
|
select * from scheduler.runs
|
|
order by created_at desc
|
|
""";
|
|
```
|
|
|
|
### 9.2 Stable Ordering for JSONB Arrays
|
|
|
|
**RULE:** When serializing arrays to JSONB, ensure consistent ordering.
|
|
|
|
```csharp
|
|
// ✓ CORRECT - Sorted before serialization
|
|
var sortedTags = schedule.Tags.OrderBy(t => t).ToList();
|
|
cmd.Parameters.AddWithValue("tags", sortedTags.ToArray());
|
|
|
|
// ✗ INCORRECT - Order may vary
|
|
cmd.Parameters.AddWithValue("tags", schedule.Tags.ToArray());
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Audit Rules
|
|
|
|
### 10.1 Timestamp Columns
|
|
|
|
**RULE:** All mutable tables MUST have `created_at` and `updated_at` columns.
|
|
|
|
```sql
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
```
|
|
|
|
### 10.2 Update Timestamp
|
|
|
|
**RULE:** `updated_at` MUST be set on every UPDATE operation.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
cmd.CommandText = """
|
|
update scheduler.schedules
|
|
set name = @name, updated_at = @updated_at
|
|
where id = @id
|
|
""";
|
|
cmd.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
|
|
|
|
// ✗ INCORRECT - Missing updated_at
|
|
cmd.CommandText = """
|
|
update scheduler.schedules
|
|
set name = @name
|
|
where id = @id
|
|
""";
|
|
```
|
|
|
|
### 10.3 Soft Delete Pattern
|
|
|
|
**RULE:** For audit-required entities, use soft delete with `deleted_at` and `deleted_by`.
|
|
|
|
```csharp
|
|
cmd.CommandText = """
|
|
update scheduler.schedules
|
|
set deleted_at = @deleted_at, deleted_by = @deleted_by
|
|
where tenant_id = @tenant_id and id = @id and deleted_at is null
|
|
""";
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Testing Rules
|
|
|
|
### 11.1 Integration Test Database
|
|
|
|
**RULE:** Integration tests MUST use Testcontainers with PostgreSQL.
|
|
|
|
```csharp
|
|
public class PostgresFixture : IAsyncLifetime
|
|
{
|
|
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
|
.WithImage("postgres:16")
|
|
.Build();
|
|
|
|
public string ConnectionString => _container.GetConnectionString();
|
|
|
|
public Task InitializeAsync() => _container.StartAsync();
|
|
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
|
|
}
|
|
```
|
|
|
|
### 11.2 Test Isolation
|
|
|
|
**RULE:** Each test MUST run in a transaction that is rolled back after the test.
|
|
|
|
```csharp
|
|
public class ScheduleRepositoryTests : IClassFixture<PostgresFixture>
|
|
{
|
|
[Fact]
|
|
public async Task GetAsync_ReturnsSchedule_WhenExists()
|
|
{
|
|
await using var connection = await _fixture.OpenConnectionAsync();
|
|
await using var transaction = await connection.BeginTransactionAsync();
|
|
|
|
try
|
|
{
|
|
// Arrange, Act, Assert
|
|
}
|
|
finally
|
|
{
|
|
await transaction.RollbackAsync();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 11.3 Determinism Tests
|
|
|
|
**RULE:** Every repository MUST have tests verifying deterministic output ordering.
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task ListAsync_ReturnsDeterministicOrder()
|
|
{
|
|
// Insert records with same created_at
|
|
// Verify order is consistent across multiple calls
|
|
var result1 = await _repository.ListAsync(tenantId);
|
|
var result2 = await _repository.ListAsync(tenantId);
|
|
|
|
result1.Should().BeEquivalentTo(result2, options =>
|
|
options.WithStrictOrdering());
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Migration Rules
|
|
|
|
### 12.1 Idempotent Migrations
|
|
|
|
**RULE:** All migrations MUST be idempotent using `IF NOT EXISTS` / `IF EXISTS`.
|
|
|
|
```sql
|
|
-- ✓ CORRECT
|
|
CREATE TABLE IF NOT EXISTS scheduler.schedules (...);
|
|
CREATE INDEX IF NOT EXISTS idx_schedules_tenant ON scheduler.schedules(tenant_id);
|
|
|
|
-- ✗ INCORRECT
|
|
CREATE TABLE scheduler.schedules (...); -- Fails if exists
|
|
```
|
|
|
|
### 12.2 No Breaking Changes
|
|
|
|
**RULE:** Migrations MUST NOT break existing code. Use expand-contract pattern.
|
|
|
|
```
|
|
Expand Phase:
|
|
1. Add new column as nullable
|
|
2. Deploy code that writes to both old and new columns
|
|
3. Backfill new column
|
|
|
|
Contract Phase:
|
|
4. Deploy code that reads from new column only
|
|
5. Add NOT NULL constraint
|
|
6. Drop old column
|
|
```
|
|
|
|
### 12.3 Index Creation
|
|
|
|
**RULE:** Large table indexes MUST be created with `CONCURRENTLY`.
|
|
|
|
```sql
|
|
-- ✓ CORRECT - Won't lock table
|
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_large_table_col
|
|
ON schema.large_table(column);
|
|
|
|
-- ✗ INCORRECT - Locks table during creation
|
|
CREATE INDEX idx_large_table_col ON schema.large_table(column);
|
|
```
|
|
|
|
---
|
|
|
|
## 13. Configuration Rules
|
|
|
|
### 13.1 Backend Selection
|
|
|
|
**RULE:** Storage backend MUST be configurable per module.
|
|
|
|
```json
|
|
{
|
|
"Persistence": {
|
|
"Authority": "Postgres",
|
|
"Scheduler": "Postgres",
|
|
"Concelier": "Postgres"
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Note:** MongoDB storage was deprecated in Sprint 4400. All modules now use PostgreSQL. MongoDB-related patterns in this document are retained for historical reference only.
|
|
|
|
### 13.2 Connection String Security
|
|
|
|
**RULE:** Connection strings MUST NOT be logged or included in exception messages.
|
|
|
|
```csharp
|
|
// ✓ CORRECT
|
|
catch (NpgsqlException ex)
|
|
{
|
|
_logger.LogError(ex, "Database connection failed for module {Module}", moduleName);
|
|
throw;
|
|
}
|
|
|
|
// ✗ INCORRECT
|
|
catch (NpgsqlException ex)
|
|
{
|
|
_logger.LogError("Failed to connect: {ConnectionString}", connectionString);
|
|
}
|
|
```
|
|
|
|
### 13.3 Timeout Configuration
|
|
|
|
**RULE:** Command timeout MUST be configurable with sensible defaults.
|
|
|
|
```csharp
|
|
public class PostgresOptions
|
|
{
|
|
public int CommandTimeoutSeconds { get; set; } = 30;
|
|
public int ConnectionTimeoutSeconds { get; set; } = 15;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 14. Documentation Rules
|
|
|
|
### 14.1 Repository Method Documentation
|
|
|
|
**RULE:** All public repository methods MUST have XML documentation.
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Retrieves a schedule by its unique identifier.
|
|
/// </summary>
|
|
/// <param name="tenantId">The tenant identifier for isolation.</param>
|
|
/// <param name="scheduleId">The schedule's unique identifier.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The schedule if found; otherwise, null.</returns>
|
|
Task<Schedule?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken);
|
|
```
|
|
|
|
### 14.2 SQL Comment Headers
|
|
|
|
**RULE:** Complex SQL queries SHOULD have a comment explaining the purpose.
|
|
|
|
```csharp
|
|
cmd.CommandText = """
|
|
-- Find schedules due to fire within the next minute
|
|
-- Uses compound index (tenant_id, next_fire_time) for efficiency
|
|
select s.id, s.name, t.next_fire_time
|
|
from scheduler.schedules s
|
|
join scheduler.triggers t on t.schedule_id = s.id
|
|
where s.tenant_id = @tenant_id
|
|
and s.enabled = true
|
|
and t.next_fire_time <= @window_end
|
|
order by t.next_fire_time asc
|
|
""";
|
|
```
|
|
|
|
---
|
|
|
|
## Enforcement
|
|
|
|
### Code Review Checklist
|
|
|
|
- [ ] Repository interfaces in Core layer
|
|
- [ ] PostgreSQL repositories prefixed with `Postgres`
|
|
- [ ] All connections disposed with `await using`
|
|
- [ ] Tenant ID required and used in all queries
|
|
- [ ] Parameterized queries (no string interpolation for values)
|
|
- [ ] Schema-qualified table names
|
|
- [ ] Explicit column lists (no `SELECT *`)
|
|
- [ ] Deterministic ORDER BY clauses
|
|
- [ ] Timestamps are UTC
|
|
- [ ] JSONB serialized with System.Text.Json
|
|
- [ ] PostgresException caught for constraint violations
|
|
- [ ] Integration tests use Testcontainers
|
|
|
|
### Automated Checks
|
|
|
|
These rules are enforced by:
|
|
- Roslyn analyzers in `StellaOps.Analyzers`
|
|
- SQL linting in CI pipeline
|
|
- Integration test requirements
|
|
|
|
---
|
|
|
|
*Document Version: 1.1.1*
|
|
*Last Updated: 2026-04-05*
|