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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.vs/
.vscode/*.code-workspace
.idea/
artifacts/
/artifacts/
out/
dist/
TestResults/
Expand Down
5 changes: 3 additions & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dotnet format ArcChat.slnx --verify-no-changes --no-restore --verbosity minimal
dotnet build ArcChat.slnx /p:TreatWarningsAsErrors=true --no-restore
#!/usr/bin/env sh

pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts/pre-commit.ps1
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />

<!-- Net: NC02 transport and provider foundations. -->
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageVersion Include="Polly.Core" Version="8.6.6" />
<PackageVersion Include="System.IO.Pipelines" Version="10.0.8" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
<PackageVersion Include="System.Threading.RateLimiting" Version="10.0.8" />

<!-- Persistence: NC02 local SQLite storage. -->
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="Dapper.AOT" Version="1.0.52" />
<PackageVersion Include="DbUp" Version="5.0.41" />
<PackageVersion Include="dbup-sqlite" Version="5.0.40" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />

<!-- Ipc: NC08.F1 deferred; NetMQ and MessagePack are intentionally not pinned in the default MVP. -->
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ dotnet run --project apps/desktop/ArcChat.Desktop/ArcChat.Desktop.csproj
pwsh scripts/install-git-hooks.ps1
```

The hook runs `dotnet format --verify-no-changes` and `dotnet build /p:TreatWarningsAsErrors=true` before commits.
The hook verifies that staged commits are not being masked by unstaged, untracked, or ignored build inputs, then runs restore, format, build, and test before commits.
20 changes: 20 additions & 0 deletions docs/coverage/nc02-protocol-shared-kernel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# NC02 Protocol And Shared Kernel Coverage

All dotnet/MSBuild validation commands are run serially with MSBuild node reuse disabled (`MSBUILDDISABLENODEREUSE=1`; `$env:MSBUILDDISABLENODEREUSE = "1"` on Windows PowerShell).

| Traceability row | Evidence added in NC02 | Validation command |
| --- | --- | --- |
| NC-PROTO-001 | `ArcChat.Protocol` chat DTOs, source-generated JSON context, NextChat conversation fixtures, and deterministic event replay. | `dotnet test packages/protocol-net/ArcChat.Protocol.Tests/ArcChat.Protocol.Tests.csproj -m:1 --no-restore /p:TreatWarningsAsErrors=true` |
| NC-PROTO-002 | Mask, plugin, MCP config/message/tool DTOs with fixture round-trip coverage. | `ProtocolRoundTripTests.MaskPluginMcpAndArtifactDtosRoundTrip` |
| NC-PROTO-003 | `HtmlArtifactPreview` and `ArcTool`; explicit test that generic artifact version/diff types stay out of the MVP protocol. | `ProtocolRoundTripTests.MvpProtocolDoesNotExposeGenericArtifactVersionOrDiffTypes` |
| NC-PROTO-004 | Provider/model DTOs preserve opaque provider-specific extra fields. | `ProtocolRoundTripTests.ProviderSettingsAndSyncDtosPreserveOpaqueExtraFields` |
| NC-PROTO-005 | Settings and sync snapshots preserve NextChat-compatible fields and `Extra` buckets. | `ProtocolRoundTripTests.ProviderSettingsAndSyncDtosPreserveOpaqueExtraFields` |
| NC-NET-001 | Named HttpClient profile factory and DI registration. | `NetCoreFactoryTests` |
| NC-NET-002 | PipeReader-backed SSE parser plus reconnect/Last-Event-ID source behavior. | `SseReaderTests` |
| NC-NET-003 | WebSocket session send/receive/heartbeat helpers and exponential reconnect policy. | `WebSocketSessionTests` |
| NC-NET-004 | HMAC-SHA256, Tencent TC3, Baidu IAM token, and iFlytek signed URL utilities. | `SigningTests` |
| NC-NET-005 | Retry-After parsing and awaitable token-bucket rate limiter. | `ResilienceAndErrorTests` |
| NC-NET-006 | Normalized `NetError` variants and HTTP/exception mapping. | `ResilienceAndErrorTests` |
| NC-CORE-008 | SQLite v1 migrations, WAL/shared-cache connection factory, single-writer queue, and conversation/message/settings repositories. | `dotnet test packages/local-persistence/ArcChat.LocalPersistence.Tests/ArcChat.LocalPersistence.Tests.csproj -m:1 --no-restore /p:TreatWarningsAsErrors=true` |

`NC-PROTO-SBE-*` remains deferred because NC08.F1 is not approved for the default MVP path.
11 changes: 11 additions & 0 deletions docs/coverage/settings-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ Source commit: `C:\MyFile\DevAll\QmlSharp\NextChat` at `89b8f26ff8f03a0c5b98fc30

This file is the NC00 field/default inventory required by `settings-schema-migration-matrix.md`. Later implementation steps replace planned DTO, persistence, binding, and test names with exact shipped names. Secrets marked `keychain` must migrate to `keychain://` references in NC15.08.

## NC02 Shipped Schema Evidence

NC02 lands the shared protocol DTOs, the local SQLite v1 schema, and repository contracts that later UI/settings steps bind to. AXAML bindings remain owned by later UI steps.

| Area | Shipped C# surface | Persistence surface | Test evidence |
| --- | --- | --- | --- |
| Chat store | `Conversation`, `Message`, `ContentBlock`, `ChatEvent` in `ArcChat.Protocol` | `Conversation` and `Message` tables; `IConversationRepository`, `IMessageRepository` | `ProtocolRoundTripTests.ConversationFixtureRoundTripsWithNextChatFields`; `RepositoryContractTests.EmptySingleAndFiveThousandMessageCasesRoundTrip`; `RepositoryContractTests.ConcurrentAppendsAreSerialized` |
| Config/settings store | `SettingsSnapshot`, `UiSettings`, `ConversationSettings`, `ProviderSettings`, `TtsSettings`, `RealtimeSettings`, `ModelConfig`, `ProviderConfig` | `Setting` table; `ISettingsRepository` stores JSON and preserves `keychain://` references opaquely | `ProtocolRoundTripTests.ProviderSettingsAndSyncDtosPreserveOpaqueExtraFields`; `RepositoryContractTests.SettingsRepositoryPreservesKeychainReferences` |
| Sync store | `SyncSnapshot`, `SyncProviderConfig` | `SyncMeta`, `SyncBackup`, and generic JSON table store | `ProtocolRoundTripTests.ProviderSettingsAndSyncDtosPreserveOpaqueExtraFields`; `MigrationAndSchemaTests.JsonBackedTablesRoundTripEveryAuxiliaryTable` |
| Mask/plugin/MCP/artifact seeds | `Mask`, `Plugin`, `PluginManifest`, `McpConfigData`, `McpRequestMessage`, `McpResponseMessage`, `McpTool`, `HtmlArtifactPreview`, `ArcTool` | `Mask`, `Plugin`, `McpServer`, `HtmlArtifactPreview`, `ToolCall`, `PromptSeed`, `KeychainRef` tables | `ProtocolRoundTripTests.MaskPluginMcpAndArtifactDtosRoundTrip`; `MigrationAndSchemaTests.JsonBackedTablesRoundTripEveryAuxiliaryTable` |

## Store Metadata

| Source | Store key | Version | Migrations found | Owner |
Expand Down
6 changes: 5 additions & 1 deletion docs/inventory/nc00-environment-toolchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,19 @@ Microsoft documents .NET 10 as LTS supported until November 2028 and documents S
| `Microsoft.Extensions.Logging` | 10.0.8 | current NuGet latest stable |
| `Microsoft.Extensions.Options` | 10.0.8 | current NuGet latest stable |
| `Microsoft.Extensions.Configuration` | 10.0.8 | current NuGet latest stable |
| `Microsoft.Extensions.Http` | 10.0.8 | NC02 named client factory; package metadata checked locally after restore |
| `System.IO.Pipelines` | 10.0.8 | package pin only if needed outside shared framework |
| `System.Net.Http` | BCL/shared framework | do not pin legacy NuGet `4.3.4` in net10 projects unless a package explicitly requires it |
| `System.Net.WebSockets.Client` | BCL/shared framework | do not pin legacy NuGet `4.3.2` in net10 projects unless a package explicitly requires it |
| `System.Threading.Channels` | 10.0.8 | current NuGet latest stable |
| `System.Threading.RateLimiting` | 10.0.8 | NC02 rate-limit foundation; package metadata checked locally after restore |
| `Polly.Core` | 8.6.6 | current NuGet latest stable |
| `Microsoft.Extensions.Http.Resilience` | 10.6.0 | current NuGet latest stable |
| `Microsoft.Data.Sqlite` | 10.0.8 | current NuGet latest stable |
| `Dapper` | 2.1.79 | NC02 repository SQL mapper; package metadata checked locally after restore |
| `Dapper.AOT` | 1.0.52 | current NuGet latest stable |
| `DbUp` | 5.0.41 | current NuGet latest stable |
| `DbUp` | 5.0.41 | central version retained for future direct use; NC02 directly references `dbup-sqlite` |
| `dbup-sqlite` | 5.0.40 | latest exact package found for SQLite migrations; restores `dbup-core` 5.0.37 transitively |
| `Microsoft.Agents.AI` | 1.6.2 | NuGet flat-container latest stable; verify package page/listed status before NC04/NC05 |
| `Markdig` | 1.2.0 | current NuGet latest stable |
| `ColorCode.Core` | 2.0.15 | current NuGet latest stable |
Expand Down
5 changes: 4 additions & 1 deletion docs/third-party-licenses.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ NC00 records license posture before dependencies are expanded in NC01. Package v
| Microsoft.Extensions.* | MIT | NuGet package metadata |
| System.* packages | MIT / .NET Foundation package license posture | NuGet package metadata |
| Polly.Core | BSD-3-Clause | https://www.nuget.org/packages/Polly.Core |
| Microsoft.Extensions.Http | MIT | https://www.nuget.org/packages/Microsoft.Extensions.Http |
| Microsoft.Extensions.Http.Resilience | MIT | NuGet package metadata |
| System.Threading.RateLimiting | MIT | https://www.nuget.org/packages/System.Threading.RateLimiting |
| Microsoft.Data.Sqlite | MIT | https://www.nuget.org/packages/Microsoft.Data.Sqlite |
| Dapper | Apache-2.0 | https://www.nuget.org/packages/Dapper |
| Dapper.AOT | Apache-2.0 | https://www.nuget.org/packages/Dapper.AOT |
| DbUp | MIT | https://www.nuget.org/packages/DbUp |
| dbup-core / dbup-sqlite | MIT | https://www.nuget.org/packages/dbup-sqlite |
| Microsoft.Agents.AI | MIT | https://www.nuget.org/packages/Microsoft.Agents.AI |
| Markdig | BSD-2-Clause | https://www.nuget.org/packages/Markdig |
| ColorCode.Core | MIT | https://www.nuget.org/packages/ColorCode.Core |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<IsTestProject>true</IsTestProject>
<AssemblyName>ArcChat.LocalPersistence.Tests</AssemblyName>
<RootNamespace>ArcChat.LocalPersistence.Tests</RootNamespace>
<NoWarn>$(NoWarn);CA1861;CA2007;MA0004;SA1000;SA1009</NoWarn>
</PropertyGroup>

<Import Project="$(ArcChatRepositoryRoot)build\ArcChat.Lib.props" />
Expand All @@ -11,6 +12,7 @@
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) ArcForges. Licensed under the MIT License.

using ArcChat.LocalPersistence.Sqlite;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Xunit;

namespace ArcChat.LocalPersistence.Tests;

public sealed class MigrationAndSchemaTests
{
[Fact]
public async Task MigrationCreatesVersionOneTablesAndEnablesWal()
{
string path = TestData.CreateDatabasePath();
await using ArcChatDatabase database = new(path);

await database.InitializeAsync(CancellationToken.None);

await using SqliteConnection connection = new($"Data Source={path};Mode=ReadWrite;Cache=Shared");
await connection.OpenAsync(CancellationToken.None);
HashSet<string> tables = new(StringComparer.Ordinal);
await using (SqliteCommand command = connection.CreateCommand())
{
command.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table';";
await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None);
while (await reader.ReadAsync(CancellationToken.None))
{
tables.Add(reader.GetString(0));
}
}

_ = tables.Should().Contain(new[]
{
"Conversation",
"Message",
"Mask",
"Plugin",
"McpServer",
"HtmlArtifactPreview",
"Setting",
"SyncMeta",
"SyncBackup",
"ToolCall",
"PromptSeed",
"KeychainRef",
});

await using SqliteCommand journalCommand = connection.CreateCommand();
journalCommand.CommandText = "PRAGMA journal_mode;";
object? journalMode = await journalCommand.ExecuteScalarAsync(CancellationToken.None);
_ = journalMode.Should().Be("wal");
}

[Fact]
public async Task JsonStoreRoundTripsEveryAuxiliaryTable()
{
string path = TestData.CreateDatabasePath();
await using ArcChatDatabase database = new(path);
await database.InitializeAsync(CancellationToken.None);

foreach (PersistenceTable table in Enum.GetValues<PersistenceTable>())
{
string id = table + "-1";
string json = "{\"table\":\"" + table + "\"}";
await database.JsonTables.UpsertAsync(table, id, json, CancellationToken.None);

string? stored = await database.JsonTables.GetAsync(table, id, CancellationToken.None);

_ = stored.Should().Be(json);
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) ArcForges. Licensed under the MIT License.

using ArcChat.Protocol.Chat;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Xunit;

namespace ArcChat.LocalPersistence.Tests;

public sealed class RepositoryContractTests
{
[Fact]
public async Task ConversationRepositoryHandlesEmptyConversation()
{
string path = TestData.CreateDatabasePath();
await using ArcChatDatabase database = new(path);
await database.InitializeAsync(CancellationToken.None);
Conversation conversation = TestData.CreateConversation("empty");

await database.Conversations.UpsertAsync(conversation, CancellationToken.None);

Conversation? stored = await database.Conversations.GetAsync("empty", CancellationToken.None);
IReadOnlyList<Conversation> all = await database.Conversations.ListAsync(CancellationToken.None);

_ = stored.Should().Be(conversation);
_ = all.Should().ContainSingle().Which.Should().Be(conversation);
}

[Fact]
public async Task MessageRepositoryHandlesSingleAndLargeBulkAppend()
{
string path = TestData.CreateDatabasePath();
await using ArcChatDatabase database = new(path);
await database.InitializeAsync(CancellationToken.None);
await database.Conversations.UpsertAsync(TestData.CreateConversation("c1"), CancellationToken.None);
Message single = TestData.CreateMessage("m-single", "single");
Message[] many = Enumerable.Range(0, 5_000)
.Select(index => TestData.CreateMessage("m-" + index.ToString(System.Globalization.CultureInfo.InvariantCulture), "body " + index.ToString(System.Globalization.CultureInfo.InvariantCulture)))
.ToArray();

await database.Messages.BulkAppendAsync("c1", new[] { single }, CancellationToken.None);
await database.Messages.BulkAppendAsync("c1", many, CancellationToken.None);

Message? storedSingle = await database.Messages.GetAsync("c1", "m-single", CancellationToken.None);
IReadOnlyList<Message> stored = await database.Messages.ListAsync("c1", CancellationToken.None);

_ = storedSingle.Should().BeEquivalentTo(single);
_ = stored.Should().HaveCount(5_001);
_ = stored[0].Id.Should().Be("m-single");
_ = stored[^1].Id.Should().Be("m-4999");
}

[Fact]
public async Task SettingsRepositoryKeepsKeychainReferencesOpaque()
{
const string KeychainReference = "keychain://provider/openai/default";
string path = TestData.CreateDatabasePath();
await using ArcChatDatabase database = new(path);
await database.InitializeAsync(CancellationToken.None);

await database.Settings.UpsertSnapshotAsync(TestData.CreateSettingsSnapshot(KeychainReference), CancellationToken.None);
await database.Settings.UpsertValueAsync("sync.webdav.passwordRef", KeychainReference, CancellationToken.None);

_ = (await database.Settings.GetSnapshotAsync(CancellationToken.None))!.Realtime.ApiKeyRef.Should().Be(KeychainReference);
string? storedReference = await database.Settings.GetValueAsync("sync.webdav.passwordRef", CancellationToken.None);
_ = storedReference.Should().Be(KeychainReference);
}

[Fact]
public async Task ConcurrentAppendsAreSerialized()
{
string path = TestData.CreateDatabasePath();
await using ArcChatDatabase database = new(path);
await database.InitializeAsync(CancellationToken.None);
await database.Conversations.UpsertAsync(TestData.CreateConversation("stream"), CancellationToken.None);

Task[] appends = Enumerable.Range(0, 20)
.Select(batch => database.Messages.BulkAppendAsync(
"stream",
Enumerable.Range(0, 10)
.Select(index => TestData.CreateMessage("m-" + batch.ToString(System.Globalization.CultureInfo.InvariantCulture) + "-" + index.ToString(System.Globalization.CultureInfo.InvariantCulture), "chunk"))
.ToArray(),
CancellationToken.None))
.ToArray();

await Task.WhenAll(appends);

IReadOnlyList<Message> stored = await database.Messages.ListAsync("stream", CancellationToken.None);
_ = stored.Should().HaveCount(200);
_ = stored.Select(message => message.Id).Should().OnlyHaveUniqueItems();
}

[Fact]
public async Task ConcurrentReaderWriterStressKeepsDatabaseValid()
{
string path = TestData.CreateDatabasePath();
await using ArcChatDatabase database = new(path);
await database.InitializeAsync(CancellationToken.None);
await database.Conversations.UpsertAsync(TestData.CreateConversation("stress"), CancellationToken.None);

Task writer = Task.Run(async () =>
{
for (int index = 0; index < 100; index++)
{
await database.Messages.BulkAppendAsync(
"stress",
new[] { TestData.CreateMessage("stress-" + index.ToString(System.Globalization.CultureInfo.InvariantCulture), "body") },
CancellationToken.None);
}
});

Task reader = Task.Run(async () =>
{
for (int index = 0; index < 100; index++)
{
_ = await database.Messages.ListAsync("stress", CancellationToken.None);
}
});

await Task.WhenAll(writer, reader);

IReadOnlyList<Message> stored = await database.Messages.ListAsync("stress", CancellationToken.None);
_ = stored.Should().HaveCount(100);

await using SqliteConnection connection = new($"Data Source={path};Mode=ReadWrite;Cache=Shared");
await connection.OpenAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "PRAGMA integrity_check;";
object? integrity = await command.ExecuteScalarAsync(CancellationToken.None);
_ = integrity.Should().Be("ok");
}
}
Loading
Loading