From 80c7885dc218388c2e800fd91ce0e8dd5729ee72 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 13:26:43 +0200 Subject: [PATCH 1/3] gitea: distinct adapter names for the note store and the fact sink Both reported as "gitea" on the status page. Co-Authored-By: Claude Fable 5.1 --- src/Wiki.Adapters.Gitea/GiteaFactSink.cs | 2 +- src/Wiki.Adapters.Gitea/GiteaNoteStore.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs b/src/Wiki.Adapters.Gitea/GiteaFactSink.cs index a5ff3eb..a430acf 100644 --- a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs +++ b/src/Wiki.Adapters.Gitea/GiteaFactSink.cs @@ -19,7 +19,7 @@ public sealed class GiteaFactSink : IFactSink private readonly HttpClient _http; private readonly GiteaOptions _options; - public string Name => "gitea"; + public string Name => "gitea-facts"; public GiteaFactSink(HttpClient httpClient, IOptions options) { diff --git a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs b/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs index 7e83748..f945d21 100644 --- a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs +++ b/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs @@ -18,7 +18,7 @@ public sealed class GiteaNoteStore : INoteStore private readonly HttpClient _http; private readonly GiteaOptions _options; - public string Name => "gitea"; + public string Name => "gitea-notes"; public GiteaNoteStore(HttpClient httpClient, IOptions options) { -- 2.54.0 From fe6a284c6cfdc742fda6c690ae37a3d9f704388e Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 13:30:17 +0200 Subject: [PATCH 2/3] gitea: one repository adapter serving both the note-store and the fact-sink port Two classes over the same backend showed as two adapters on the status page. One GiteaRepository, one HttpClient, one health entry; the host lists an adapter once even when it serves several ports. Co-Authored-By: Claude Fable 5.1 --- src/Wiki.Adapters.Gitea/GiteaHttp.cs | 2 +- src/Wiki.Adapters.Gitea/GiteaNoteStore.cs | 97 ------------------- .../{GiteaFactSink.cs => GiteaRepository.cs} | 89 ++++++++++++++--- .../ServiceCollectionExtensions.cs | 21 ++-- src/Wiki.Host/Program.cs | 19 ++-- .../Gitea/GiteaFactSinkTests.cs | 4 +- .../Gitea/GiteaNoteStoreContractTests.cs | 2 +- 7 files changed, 105 insertions(+), 129 deletions(-) delete mode 100644 src/Wiki.Adapters.Gitea/GiteaNoteStore.cs rename src/Wiki.Adapters.Gitea/{GiteaFactSink.cs => GiteaRepository.cs} (58%) diff --git a/src/Wiki.Adapters.Gitea/GiteaHttp.cs b/src/Wiki.Adapters.Gitea/GiteaHttp.cs index bf43add..fd7fa50 100644 --- a/src/Wiki.Adapters.Gitea/GiteaHttp.cs +++ b/src/Wiki.Adapters.Gitea/GiteaHttp.cs @@ -3,7 +3,7 @@ using System.Text.Json; namespace Wiki.Adapters.Gitea; -/// HTTP plumbing shared by and : +/// HTTP plumbing shared by : /// client setup, JSON decoding and turning a failed response into a message worth reading. internal static class GiteaHttp { diff --git a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs b/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs deleted file mode 100644 index f945d21..0000000 --- a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Diagnostics; -using Microsoft.Extensions.Options; -using Wiki.Core.Model; -using Wiki.Core.Parsing; -using Wiki.Core.Ports; - -namespace Wiki.Adapters.Gitea; - -/// Reads the vault straight from a Gitea repository: HEAD, a recursive tree listing and raw -/// file contents, all at the commit the listing was taken from. -public sealed class GiteaNoteStore : INoteStore -{ - // Gitea answers "tree": null past the last page, and may mark the last page truncated=true even - // though nothing follows; stop on a short/empty page or once total_count is reached either way. - private const int PageSize = 1000; - private const int MaxPages = 50; - - private readonly HttpClient _http; - private readonly GiteaOptions _options; - - public string Name => "gitea-notes"; - - public GiteaNoteStore(HttpClient httpClient, IOptions options) - { - _http = httpClient; - _options = options.Value; - GiteaHttp.Configure(_http, _options); - } - - public async Task GetHeadAsync(CancellationToken ct) - { - var branch = await FetchBranchAsync(ct).ConfigureAwait(false); - return new StoreHead(branch.Commit.Id, branch.Commit.Timestamp); - } - - public async Task> ListAsync(string commit, IReadOnlySet kinds, CancellationToken ct) - { - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var refs = new List(); - var page = 1; - var seen = 0; - while (true) - { - var url = $"api/v1/repos/{repo}/git/trees/{Uri.EscapeDataString(commit)}?recursive=true&per_page={PageSize}&page={page}"; - var tree = await GiteaHttp.GetJsonAsync(_http, url, ct).ConfigureAwait(false); - var entries = tree.Tree ?? []; - foreach (var entry in entries) - { - if (entry.Type != "blob") continue; - if (DocumentParser.Classify(entry.Path) is not { } classified) continue; - if (!kinds.Contains(classified.Kind)) continue; - refs.Add(new DocumentRef(classified.Kind, classified.Slug, entry.Path, entry.Sha)); - } - seen += entries.Count; - // total_count is the reliable signal (confirmed live: Gitea reports it regardless of the - // per_page requested); a short page only means "done" when total_count isn't available, - // since a server may cap the effective page size below what was asked for. - var done = entries.Count == 0 - || (tree.TotalCount > 0 ? seen >= tree.TotalCount : entries.Count < PageSize); - if (done) break; - page++; - if (page > MaxPages) throw new InvalidOperationException("Gitea tree paging exceeded the safety cap."); - } - return refs; - } - - public async Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct) - { - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var path = GiteaUrls.EncodeSegments(reference.Path); - var url = $"api/v1/repos/{repo}/raw/{path}?ref={Uri.EscapeDataString(commit)}"; - using var response = await _http.GetAsync(url, ct).ConfigureAwait(false); - await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); - return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - } - - public async Task CheckAsync(CancellationToken ct) - { - var sw = Stopwatch.StartNew(); - try - { - var branch = await FetchBranchAsync(ct).ConfigureAwait(false); - return new AdapterHealth(Name, true, $"HEAD {branch.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow); - } - catch (Exception ex) - { - return new AdapterHealth(Name, false, ex.Message, sw.Elapsed, DateTimeOffset.UtcNow); - } - } - - private Task FetchBranchAsync(CancellationToken ct) - { - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var branch = Uri.EscapeDataString(_options.Branch); - return GiteaHttp.GetJsonAsync(_http, $"api/v1/repos/{repo}/branches/{branch}", ct); - } -} diff --git a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs b/src/Wiki.Adapters.Gitea/GiteaRepository.cs similarity index 58% rename from src/Wiki.Adapters.Gitea/GiteaFactSink.cs rename to src/Wiki.Adapters.Gitea/GiteaRepository.cs index a430acf..8051560 100644 --- a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs +++ b/src/Wiki.Adapters.Gitea/GiteaRepository.cs @@ -5,29 +5,87 @@ using System.Net.Http.Json; using System.Text; using Microsoft.Extensions.Options; using Wiki.Core.Model; +using Wiki.Core.Parsing; using Wiki.Core.Ports; namespace Wiki.Adapters.Gitea; -/// Deposits a fact as a new file under raw/ in Gitea. Never edits an existing file: a -/// title that would collide with an existing path at HEAD gets a numbered suffix instead. -public sealed class GiteaFactSink : IFactSink +/// +/// One Gitea repository as the vault's storage: reads (HEAD, recursive tree listing, raw file +/// contents at a commit) and the single kind of write the design allows, a new raw source file. +/// One adapter, one backend, one health entry. +/// +public sealed class GiteaRepository : INoteStore, IFactSink { + // Gitea answers "tree": null past the last page, and may mark the last page truncated=true even + // though nothing follows; stop on a short/empty page or once total_count is reached either way. + private const int PageSize = 1000; + private const int MaxPages = 50; private const int MaxSlugLength = 80; private const int MaxSuffixAttempts = 1000; private readonly HttpClient _http; private readonly GiteaOptions _options; - public string Name => "gitea-facts"; + public string Name => "gitea"; - public GiteaFactSink(HttpClient httpClient, IOptions options) + public GiteaRepository(HttpClient httpClient, IOptions options) { _http = httpClient; _options = options.Value; GiteaHttp.Configure(_http, _options); } + // ---- INoteStore ---------------------------------------------------------------------- + + public async Task GetHeadAsync(CancellationToken ct) + { + var branch = await FetchBranchAsync(ct).ConfigureAwait(false); + return new StoreHead(branch.Commit.Id, branch.Commit.Timestamp); + } + + public async Task> ListAsync(string commit, IReadOnlySet kinds, CancellationToken ct) + { + var repo = GiteaUrls.EncodeSegments(_options.Repository); + var refs = new List(); + var page = 1; + var seen = 0; + while (true) + { + var url = $"api/v1/repos/{repo}/git/trees/{Uri.EscapeDataString(commit)}?recursive=true&per_page={PageSize}&page={page}"; + var tree = await GiteaHttp.GetJsonAsync(_http, url, ct).ConfigureAwait(false); + var entries = tree.Tree ?? []; + foreach (var entry in entries) + { + if (entry.Type != "blob") continue; + if (DocumentParser.Classify(entry.Path) is not { } classified) continue; + if (!kinds.Contains(classified.Kind)) continue; + refs.Add(new DocumentRef(classified.Kind, classified.Slug, entry.Path, entry.Sha)); + } + seen += entries.Count; + // total_count is the reliable signal (confirmed live: Gitea reports it regardless of the + // per_page requested); a short page only means "done" when total_count isn't available. + var done = entries.Count == 0 + || (tree.TotalCount > 0 ? seen >= tree.TotalCount : entries.Count < PageSize); + if (done) break; + page++; + if (page > MaxPages) throw new InvalidOperationException("Gitea tree paging exceeded the safety cap."); + } + return refs; + } + + public async Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct) + { + var repo = GiteaUrls.EncodeSegments(_options.Repository); + var path = GiteaUrls.EncodeSegments(reference.Path); + var url = $"api/v1/repos/{repo}/raw/{path}?ref={Uri.EscapeDataString(commit)}"; + using var response = await _http.GetAsync(url, ct).ConfigureAwait(false); + await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + + // ---- IFactSink ----------------------------------------------------------------------- + public async Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct) { var errors = fact.Validate(); @@ -55,16 +113,15 @@ public sealed class GiteaFactSink : IFactSink return new FactReceipt(path, created.Commit.Sha, now); } + // ---- IAdapterHealth ------------------------------------------------------------------ + public async Task CheckAsync(CancellationToken ct) { var sw = Stopwatch.StartNew(); try { - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var branch = Uri.EscapeDataString(_options.Branch); - var head = await GiteaHttp.GetJsonAsync(_http, $"api/v1/repos/{repo}/branches/{branch}", ct) - .ConfigureAwait(false); - return new AdapterHealth(Name, true, $"HEAD {head.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow); + var branch = await FetchBranchAsync(ct).ConfigureAwait(false); + return new AdapterHealth(Name, true, $"HEAD {branch.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow); } catch (Exception ex) { @@ -72,6 +129,15 @@ public sealed class GiteaFactSink : IFactSink } } + // ---- internals ----------------------------------------------------------------------- + + private Task FetchBranchAsync(CancellationToken ct) + { + var repo = GiteaUrls.EncodeSegments(_options.Repository); + var branch = Uri.EscapeDataString(_options.Branch); + return GiteaHttp.GetJsonAsync(_http, $"api/v1/repos/{repo}/branches/{branch}", ct); + } + /// The candidate path if free at HEAD, else the same with -2, -3, ... appended until one is free. private async Task ReservePathAsync(string datePrefix, string slug, CancellationToken ct) { @@ -116,8 +182,7 @@ public sealed class GiteaFactSink : IFactSink return sb.ToString(); } - /// Quotes a scalar that would otherwise be ambiguous or wrong in flow-less YAML: a colon - /// or a '#' can start a mapping value or a comment mid-line, and a leading quote needs escaping. + /// Quotes a scalar that would otherwise be ambiguous in flow-less YAML. private static string YamlScalar(string value) { if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\'')) diff --git a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs index c2a8ff6..daf92a5 100644 --- a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs +++ b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs @@ -1,24 +1,27 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Wiki.Core.Ports; namespace Wiki.Adapters.Gitea; public static class ServiceCollectionExtensions { - /// Registers the Gitea-backed and , each - /// with its own managed HttpClient from . Reads retry transient - /// failures; pooled connections are recycled so a stale keep-alive is not reused for long. + private const string ClientName = "gitea"; + + /// Registers one as both and + /// . Reads retry transient failures; pooled connections are recycled so a + /// stale keep-alive is not reused for long. public static IServiceCollection AddGiteaAdapters(this IServiceCollection services, Action configure) { services.Configure(configure); - services.AddHttpClient() + services.AddHttpClient(ClientName) .AddHttpMessageHandler(() => new TransientRetryHandler()) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }); - services.AddHttpClient() - .AddHttpMessageHandler(() => new TransientRetryHandler()) - .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }); - services.AddTransient(sp => sp.GetRequiredService()); - services.AddTransient(sp => sp.GetRequiredService()); + services.AddSingleton(sp => new GiteaRepository( + sp.GetRequiredService().CreateClient(ClientName), + sp.GetRequiredService>())); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); return services; } } diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index 17fc409..fa16414 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -47,14 +47,19 @@ foreach (var (name, model) in wiki.Embeddings.Models) builder.Services.AddSingleton>(sp => wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService).ToList()); builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.Embeddings.Active)); +// One adapter may serve several ports (Gitea is note store and fact sink); list it once. builder.Services.AddSingleton>(sp => -[ - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - .. sp.GetRequiredService>(), -]); +{ + List all = + [ + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + .. sp.GetRequiredService>(), + ]; + return all.Distinct().ToList(); +}); // ---- use cases --------------------------------------------------------------------------- builder.Services.AddSingleton(new SyncOptions()); diff --git a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs b/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs index 96d2042..b809fd9 100644 --- a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs +++ b/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs @@ -36,7 +36,7 @@ public sealed class GiteaFactSinkTests "\n" + "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n"; - private static (FakeGiteaHandler Handler, GiteaFactSink Sink) CreateSink() + private static (FakeGiteaHandler Handler, GiteaRepository Sink) CreateSink() { var handler = new FakeGiteaHandler(Repository, Branch, pageSize: 3); var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://gitea.test") }; @@ -47,7 +47,7 @@ public sealed class GiteaFactSinkTests Branch = Branch, Token = "test-token", }); - return (handler, new GiteaFactSink(httpClient, options)); + return (handler, new GiteaRepository(httpClient, options)); } [Fact] diff --git a/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs b/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs index 59853af..71966ff 100644 --- a/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs +++ b/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs @@ -20,6 +20,6 @@ public sealed class GiteaNoteStoreContractTests : NoteStoreContract Branch = Branch, Token = "test-token", }); - return Task.FromResult(new GiteaNoteStore(httpClient, options)); + return Task.FromResult(new GiteaRepository(httpClient, options)); } } -- 2.54.0 From 6ff8d4bce6a920e6d9d9ea9e9c04c76ee7ec5be6 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 13:37:20 +0200 Subject: [PATCH 3/3] store port: get and create documents; depositing a fact is a use case The fact sink was a second adapter over the same backend. INoteStore gains CreateAsync (DocumentExistsException on a taken path); the raw note's shape, slug and collision numbering live in DepositFactUseCase; GiteaRepository only moves text. Co-Authored-By: Claude Fable 5.1 --- src/Wiki.Adapters.Gitea/GiteaRepository.cs | 118 ++--------------- .../ServiceCollectionExtensions.cs | 11 +- .../Facts/DepositFactUseCase.cs | 94 ++++++++++++++ src/Wiki.Core/Model/Retrieval.cs | 9 ++ src/Wiki.Core/Ports/Ports.cs | 12 +- src/Wiki.Host/Program.cs | 3 +- .../DepositFactUseCaseTests.cs | 71 ++++++++++ test/Wiki.Application.Tests/Fakes.cs | 10 ++ .../Wiki.Application.Tests.csproj | 1 + .../Gitea/GiteaFactSinkTests.cs | 121 ------------------ test/Wiki.Contracts/NoteStoreContract.cs | 19 +++ test/Wiki.Host.Tests/EndpointTests.cs | 2 - test/Wiki.Host.Tests/Fakes.cs | 14 +- 13 files changed, 231 insertions(+), 254 deletions(-) create mode 100644 src/Wiki.Application/Facts/DepositFactUseCase.cs create mode 100644 test/Wiki.Application.Tests/DepositFactUseCaseTests.cs delete mode 100644 test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs diff --git a/src/Wiki.Adapters.Gitea/GiteaRepository.cs b/src/Wiki.Adapters.Gitea/GiteaRepository.cs index 8051560..48676de 100644 --- a/src/Wiki.Adapters.Gitea/GiteaRepository.cs +++ b/src/Wiki.Adapters.Gitea/GiteaRepository.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Globalization; using System.Net; using System.Net.Http.Json; using System.Text; @@ -11,18 +10,15 @@ using Wiki.Core.Ports; namespace Wiki.Adapters.Gitea; /// -/// One Gitea repository as the vault's storage: reads (HEAD, recursive tree listing, raw file -/// contents at a commit) and the single kind of write the design allows, a new raw source file. -/// One adapter, one backend, one health entry. +/// One Gitea repository as the vault's storage: HEAD, a recursive tree listing, raw file contents +/// at a commit, and creating a new file on the branch. Knows paths and text, nothing about notes. /// -public sealed class GiteaRepository : INoteStore, IFactSink +public sealed class GiteaRepository : INoteStore { // Gitea answers "tree": null past the last page, and may mark the last page truncated=true even // though nothing follows; stop on a short/empty page or once total_count is reached either way. private const int PageSize = 1000; private const int MaxPages = 50; - private const int MaxSlugLength = 80; - private const int MaxSuffixAttempts = 1000; private readonly HttpClient _http; private readonly GiteaOptions _options; @@ -36,8 +32,6 @@ public sealed class GiteaRepository : INoteStore, IFactSink GiteaHttp.Configure(_http, _options); } - // ---- INoteStore ---------------------------------------------------------------------- - public async Task GetHeadAsync(CancellationToken ct) { var branch = await FetchBranchAsync(ct).ConfigureAwait(false); @@ -84,37 +78,27 @@ public sealed class GiteaRepository : INoteStore, IFactSink return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } - // ---- IFactSink ----------------------------------------------------------------------- - - public async Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct) + /// POST contents: Gitea answers 422 when the path already exists on the branch. + public async Task CreateAsync(string path, string text, string message, CancellationToken ct) { - var errors = fact.Validate(); - if (errors.Count > 0) - throw new ArgumentException($"Invalid raw source: {string.Join("; ", errors)}", nameof(fact)); - - var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - var slug = Slugify(fact.Title); - var path = await ReservePathAsync(datePrefix, slug, ct).ConfigureAwait(false); - var text = BuildFile(fact, by, now, datePrefix); - var repo = GiteaUrls.EncodeSegments(_options.Repository); var encodedPath = GiteaUrls.EncodeSegments(path); var request = new { content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)), - message = $"raw: {fact.Title}", + message, branch = _options.Branch, }; using var response = await _http.PostAsJsonAsync($"api/v1/repos/{repo}/contents/{encodedPath}", request, GiteaHttp.Json, ct) .ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.UnprocessableEntity) + throw new DocumentExistsException(path); await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); var created = await response.Content.ReadFromJsonAsync(GiteaHttp.Json, ct).ConfigureAwait(false) ?? throw new InvalidOperationException("Gitea returned an empty body creating a file."); - return new FactReceipt(path, created.Commit.Sha, now); + return new StoreWrite(path, created.Commit.Sha); } - // ---- IAdapterHealth ------------------------------------------------------------------ - public async Task CheckAsync(CancellationToken ct) { var sw = Stopwatch.StartNew(); @@ -129,94 +113,10 @@ public sealed class GiteaRepository : INoteStore, IFactSink } } - // ---- internals ----------------------------------------------------------------------- - private Task FetchBranchAsync(CancellationToken ct) { var repo = GiteaUrls.EncodeSegments(_options.Repository); var branch = Uri.EscapeDataString(_options.Branch); return GiteaHttp.GetJsonAsync(_http, $"api/v1/repos/{repo}/branches/{branch}", ct); } - - /// The candidate path if free at HEAD, else the same with -2, -3, ... appended until one is free. - private async Task ReservePathAsync(string datePrefix, string slug, CancellationToken ct) - { - var candidate = $"raw/{datePrefix}-{slug}.md"; - for (var attempt = 2; await ExistsAtHeadAsync(candidate, ct).ConfigureAwait(false); attempt++) - { - if (attempt > MaxSuffixAttempts) - throw new InvalidOperationException($"Gitea has {MaxSuffixAttempts}+ raw files named like {datePrefix}-{slug}; giving up."); - candidate = $"raw/{datePrefix}-{slug}-{attempt}.md"; - } - return candidate; - } - - private async Task ExistsAtHeadAsync(string path, CancellationToken ct) - { - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var encodedPath = GiteaUrls.EncodeSegments(path); - var branch = Uri.EscapeDataString(_options.Branch); - using var response = await _http.GetAsync($"api/v1/repos/{repo}/contents/{encodedPath}?ref={branch}", ct).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.NotFound) return false; - await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); - return true; - } - - private static string BuildFile(RawSource fact, Identity by, DateTimeOffset now, string capturedDate) - { - var body = fact.Content; - if (!body.EndsWith('\n')) body += "\n"; - - var sb = new StringBuilder(); - sb.Append("---\n"); - sb.Append("title: ").Append(YamlScalar(fact.Title)).Append('\n'); - sb.Append("type: raw\n"); - sb.Append("source: ").Append(YamlScalar(fact.Source)).Append('\n'); - sb.Append("captured: ").Append(capturedDate).Append('\n'); - sb.Append("captured_by: ").Append(by.Name).Append('\n'); - sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n'); - sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n"); - sb.Append("---\n"); - sb.Append('\n'); - sb.Append(body); - return sb.ToString(); - } - - /// Quotes a scalar that would otherwise be ambiguous in flow-less YAML. - private static string YamlScalar(string value) - { - if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\'')) - return value; - return $"\"{value.Replace("\"", "\\\"")}\""; - } - - private static string Slugify(string title) - { - var sb = new StringBuilder(title.Length); - foreach (var ch in title.ToLowerInvariant()) - { - if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9')) sb.Append(ch); - else sb.Append('-'); - } - - var collapsed = new StringBuilder(sb.Length); - var lastWasDash = false; - foreach (var ch in sb.ToString()) - { - if (ch == '-') - { - if (lastWasDash) continue; - lastWasDash = true; - } - else - { - lastWasDash = false; - } - collapsed.Append(ch); - } - - var slug = collapsed.ToString().Trim('-'); - if (slug.Length > MaxSlugLength) slug = slug[..MaxSlugLength].Trim('-'); - return slug.Length == 0 ? "untitled" : slug; - } } diff --git a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs index daf92a5..f9afdb2 100644 --- a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs +++ b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs @@ -8,20 +8,17 @@ public static class ServiceCollectionExtensions { private const string ClientName = "gitea"; - /// Registers one as both and - /// . Reads retry transient failures; pooled connections are recycled so a - /// stale keep-alive is not reused for long. - public static IServiceCollection AddGiteaAdapters(this IServiceCollection services, Action configure) + /// Registers as the . Reads retry + /// transient failures; pooled connections are recycled so a stale keep-alive is not reused for long. + public static IServiceCollection AddGiteaNoteStore(this IServiceCollection services, Action configure) { services.Configure(configure); services.AddHttpClient(ClientName) .AddHttpMessageHandler(() => new TransientRetryHandler()) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }); - services.AddSingleton(sp => new GiteaRepository( + services.AddSingleton(sp => new GiteaRepository( sp.GetRequiredService().CreateClient(ClientName), sp.GetRequiredService>())); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); return services; } } diff --git a/src/Wiki.Application/Facts/DepositFactUseCase.cs b/src/Wiki.Application/Facts/DepositFactUseCase.cs new file mode 100644 index 0000000..7a7f52d --- /dev/null +++ b/src/Wiki.Application/Facts/DepositFactUseCase.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using System.Text; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Application.Facts; + +/// +/// Turns a fact into a raw source note and stores it: provenance frontmatter in the vault's +/// shape, a dated slug path under raw/, a numbered suffix when the path is taken. The store only +/// ever sees a path and text. +/// +public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock) +{ + private const int MaxSlugLength = 80; + private const int MaxSuffixAttempts = 1000; + + public async Task DepositAsync(RawSource fact, Identity by, CancellationToken ct) + { + var errors = fact.Validate(); + if (errors.Count > 0) + throw new ArgumentException($"Invalid raw source: {string.Join("; ", errors)}", nameof(fact)); + + var now = clock.GetUtcNow(); + var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var slug = Slugify(fact.Title); + var text = BuildNote(fact, by, datePrefix); + var message = $"raw: {fact.Title}"; + + var path = $"raw/{datePrefix}-{slug}.md"; + for (var attempt = 2; ; attempt++) + { + try + { + var written = await store.CreateAsync(path, text, message, ct); + return new FactReceipt(written.Path, written.Commit, now); + } + catch (DocumentExistsException) when (attempt <= MaxSuffixAttempts) + { + path = $"raw/{datePrefix}-{slug}-{attempt}.md"; + } + } + } + + public static string BuildNote(RawSource fact, Identity by, string capturedDate) + { + var body = fact.Content; + if (!body.EndsWith('\n')) body += "\n"; + + var sb = new StringBuilder(); + sb.Append("---\n"); + sb.Append("title: ").Append(YamlScalar(fact.Title)).Append('\n'); + sb.Append("type: raw\n"); + sb.Append("source: ").Append(YamlScalar(fact.Source)).Append('\n'); + sb.Append("captured: ").Append(capturedDate).Append('\n'); + sb.Append("captured_by: ").Append(by.Name).Append('\n'); + sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n'); + sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n"); + sb.Append("---\n"); + sb.Append('\n'); + sb.Append(body); + return sb.ToString(); + } + + /// Quotes a scalar that would otherwise be ambiguous in flow-less YAML. + private static string YamlScalar(string value) + { + if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\'')) + return value; + return $"\"{value.Replace("\"", "\\\"")}\""; + } + + public static string Slugify(string title) + { + var sb = new StringBuilder(title.Length); + var lastWasDash = false; + foreach (var ch in title.ToLowerInvariant()) + { + if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9')) + { + sb.Append(ch); + lastWasDash = false; + } + else if (!lastWasDash) + { + sb.Append('-'); + lastWasDash = true; + } + } + var slug = sb.ToString().Trim('-'); + if (slug.Length > MaxSlugLength) slug = slug[..MaxSlugLength].Trim('-'); + return slug.Length == 0 ? "untitled" : slug; + } +} diff --git a/src/Wiki.Core/Model/Retrieval.cs b/src/Wiki.Core/Model/Retrieval.cs index ff61934..f3cdc70 100644 --- a/src/Wiki.Core/Model/Retrieval.cs +++ b/src/Wiki.Core/Model/Retrieval.cs @@ -14,3 +14,12 @@ public sealed record IndexState(string ModelId, IReadOnlyDictionaryHead of the note store: the commit the listing is taken from. public sealed record StoreHead(string Commit, DateTimeOffset? CommittedAt); + +/// Result of creating a document in the store. +public sealed record StoreWrite(string Path, string Commit); + +/// The path is already taken; the caller picks another. +public sealed class DocumentExistsException(string path) : Exception($"document already exists: {path}") +{ + public string Path { get; } = path; +} diff --git a/src/Wiki.Core/Ports/Ports.cs b/src/Wiki.Core/Ports/Ports.cs index 1c0a039..9378bcd 100644 --- a/src/Wiki.Core/Ports/Ports.cs +++ b/src/Wiki.Core/Ports/Ports.cs @@ -9,7 +9,10 @@ public interface IAdapterHealth Task CheckAsync(CancellationToken ct); } -/// Where the vault's documents are read from. +/// +/// Where the vault's documents live: read at a head, and create a new document. What a document +/// means (a note, a raw source, a report) is the application's business; the store moves text. +/// public interface INoteStore : IAdapterHealth { /// The current head; a listing is always taken at a head. @@ -20,12 +23,9 @@ public interface INoteStore : IAdapterHealth /// The document's text at that head. Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct); -} -/// Where a deposited fact goes: one new raw source, never an edit of an existing file. -public interface IFactSink : IAdapterHealth -{ - Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct); + /// Creates a new document at the path on the current head. Throws when the path is taken. + Task CreateAsync(string path, string text, string message, CancellationToken ct); } /// Text to vector. One instance per configured model. diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index fa16414..ecf0d9e 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -18,7 +18,7 @@ builder.Services.AddSingleton(wiki); builder.Services.AddSingleton(TimeProvider.System); // ---- adapters, selected by configuration ------------------------------------------------ -builder.Services.AddGiteaAdapters(o => +builder.Services.AddGiteaNoteStore(o => { o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl; o.Repository = wiki.NoteStore.Gitea.Repository; @@ -53,7 +53,6 @@ builder.Services.AddSingleton>(sp => List all = [ sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), .. sp.GetRequiredService>(), diff --git a/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs b/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs new file mode 100644 index 0000000..4ba53be --- /dev/null +++ b/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Time.Testing; +using Wiki.Application.Facts; +using Wiki.Core.Model; + +namespace Wiki.Application.Tests; + +public class DepositFactUseCaseTests +{ + private static readonly RawSource Fact = new( + Title: "UDM intercepts DNS: a follow-up", + Source: "https://example.com/thread#5", + Content: "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.", + ObservedAt: new DateTimeOffset(2026, 9, 20, 14, 30, 0, TimeSpan.Zero), + Tags: ["dns", "udm"]); + + private static readonly Identity By = new("agent-x", IdentityKind.Machine); + private static readonly DateTimeOffset Now = new(2026, 9, 23, 8, 0, 0, TimeSpan.Zero); + private const string ExpectedPath = "raw/2026-09-23-udm-intercepts-dns-a-follow-up.md"; + + private const string ExpectedText = + "---\n" + + "title: \"UDM intercepts DNS: a follow-up\"\n" + + "type: raw\n" + + "source: \"https://example.com/thread#5\"\n" + + "captured: 2026-09-23\n" + + "captured_by: agent-x\n" + + "observed: 2026-09-20T14:30:00+00:00\n" + + "tags: [dns, udm]\n" + + "---\n" + + "\n" + + "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n"; + + private readonly FakeStore _store = new(); + private DepositFactUseCase UseCase => new(_store, new FakeTimeProvider(Now)); + + [Fact] + public async Task WritesTheRawNoteAtTheDatedSlugPath() + { + var receipt = await UseCase.DepositAsync(Fact, By, CancellationToken.None); + Assert.Equal(ExpectedPath, receipt.Path); + Assert.Equal(Now, receipt.StoredAt); + Assert.Equal(ExpectedText, _store.Files[ExpectedPath]); + Assert.Equal(("raw: UDM intercepts DNS: a follow-up", ExpectedPath), _store.Created.Single()); + } + + [Fact] + public async Task NumbersThePathWhileItCollides() + { + _store.Files[ExpectedPath] = "already here"; + _store.Files["raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md"] = "also here"; + var receipt = await UseCase.DepositAsync(Fact, By, CancellationToken.None); + Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-3.md", receipt.Path); + } + + [Theory] + [InlineData("", "source", "content long enough to pass the minimum length check, easily.")] + [InlineData("title", "", "content long enough to pass the minimum length check, easily.")] + [InlineData("title", "source", "too short")] + public async Task RejectsAnInvalidFactBeforeTouchingTheStore(string title, string source, string content) + { + var invalid = new RawSource(title, source, content, Now, []); + await Assert.ThrowsAsync(() => UseCase.DepositAsync(invalid, By, CancellationToken.None)); + Assert.Empty(_store.Created); + } + + [Theory] + [InlineData("Talos v1.13.10: aes256k / kernel 6.18!", "talos-v1-13-10-aes256k-kernel-6-18")] + [InlineData(" ", "untitled")] + [InlineData("Ünïcödé näme", "n-c-d-n-me")] + public void SlugifiesTitles(string title, string slug) => Assert.Equal(slug, DepositFactUseCase.Slugify(title)); +} diff --git a/test/Wiki.Application.Tests/Fakes.cs b/test/Wiki.Application.Tests/Fakes.cs index 2d591a5..2c24003 100644 --- a/test/Wiki.Application.Tests/Fakes.cs +++ b/test/Wiki.Application.Tests/Fakes.cs @@ -34,6 +34,16 @@ public sealed class FakeStore : INoteStore return Task.FromResult(Files[reference.Path]); } + public List<(string Message, string Path)> Created { get; } = []; + + public Task CreateAsync(string path, string text, string message, CancellationToken ct) + { + if (Files.ContainsKey(path)) throw new DocumentExistsException(path); + Files[path] = text; + Created.Add((message, path)); + return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(text))))); + } + public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); } diff --git a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj index 9d03af8..e190d11 100644 --- a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj +++ b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs b/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs deleted file mode 100644 index b809fd9..0000000 --- a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Microsoft.Extensions.Options; -using Wiki.Adapters.Gitea; -using Wiki.Core.Model; - -namespace Wiki.Contracts.Gitea; - -public sealed class GiteaFactSinkTests -{ - private const string Repository = "k.renner/notes"; - private const string Branch = "main"; - - private static readonly RawSource Fact = new( - Title: "UDM intercepts DNS: a follow-up", - Source: "https://example.com/thread#5", - Content: "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.", - ObservedAt: new DateTimeOffset(2026, 9, 20, 14, 30, 0, TimeSpan.Zero), - Tags: ["dns", "udm"]); - - private static readonly Identity By = new("agent-x", IdentityKind.Machine); - private static readonly DateTimeOffset Now = new(2026, 9, 23, 8, 0, 0, TimeSpan.Zero); - - private const string ExpectedPath = "raw/2026-09-23-udm-intercepts-dns-a-follow-up.md"; - - private static readonly string ExpectedText = - "---\n" + - "title: \"UDM intercepts DNS: a follow-up\"\n" + - "type: raw\n" + - "source: \"https://example.com/thread#5\"\n" + - "captured: 2026-09-23\n" + - "captured_by: agent-x\n" + - "observed: 2026-09-20T14:30:00+00:00\n" + - "tags: [dns, udm]\n" + - "---\n" + - "\n" + - "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n"; - - private static (FakeGiteaHandler Handler, GiteaRepository Sink) CreateSink() - { - var handler = new FakeGiteaHandler(Repository, Branch, pageSize: 3); - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://gitea.test") }; - var options = Options.Create(new GiteaOptions - { - BaseUrl = "https://gitea.test", - Repository = Repository, - Branch = Branch, - Token = "test-token", - }); - return (handler, new GiteaRepository(httpClient, options)); - } - - [Fact] - public async Task WritesTheExpectedPathAndFrontmatter() - { - var (handler, sink) = CreateSink(); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal(ExpectedPath, receipt.Path); - Assert.Equal(Now, receipt.StoredAt); - - var created = Assert.Single(handler.Created); - Assert.Equal(ExpectedPath, created.Path); - Assert.Equal(ExpectedText, created.Content); - Assert.Equal(Branch, created.Branch); - } - - [Fact] - public async Task ReturnsTheCommitShaFromTheResponse() - { - var (_, sink) = CreateSink(); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal(Sha1Hex(ExpectedText), receipt.Commit); - } - - [Fact] - public async Task AppendsANumberedSuffixWhenThePathAlreadyExists() - { - var (handler, sink) = CreateSink(); - handler.SeedFile(ExpectedPath, "already here"); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", receipt.Path); - } - - [Fact] - public async Task KeepsNumberingWhileCandidatesCollide() - { - var (handler, sink) = CreateSink(); - handler.SeedFile(ExpectedPath, "already here"); - handler.SeedFile("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", "also here"); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-3.md", receipt.Path); - } - - [Theory] - [InlineData("", "source", "content long enough to pass the minimum length check, easily.")] - [InlineData("title", "", "content long enough to pass the minimum length check, easily.")] - [InlineData("title", "source", "too short")] - public async Task RejectsAnInvalidRawSourceBeforeCallingGitea(string title, string source, string content) - { - var (handler, sink) = CreateSink(); - var invalid = new RawSource(title, source, content, Now, []); - - await Assert.ThrowsAsync(() => sink.AppendAsync(invalid, By, Now, CancellationToken.None)); - - Assert.Empty(handler.Created); - } - - private static string Sha1Hex(string content) - { - var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(content)); - return Convert.ToHexStringLower(bytes); - } -} diff --git a/test/Wiki.Contracts/NoteStoreContract.cs b/test/Wiki.Contracts/NoteStoreContract.cs index 86fc37d..3fed37f 100644 --- a/test/Wiki.Contracts/NoteStoreContract.cs +++ b/test/Wiki.Contracts/NoteStoreContract.cs @@ -57,6 +57,25 @@ public abstract class NoteStoreContract } } + [Fact] + public async Task CreatesANewDocumentAndRefusesAnExistingPath() + { + var store = await CreateStoreAsync(); + var written = await store.CreateAsync("raw/2026-09-24-new.md", "---\ntitle: New\n---\n\nText.\n", "raw: New", CancellationToken.None); + Assert.Equal("raw/2026-09-24-new.md", written.Path); + Assert.False(string.IsNullOrWhiteSpace(written.Commit)); + + var head = await store.GetHeadAsync(CancellationToken.None); + var refs = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); + var created = Assert.Single(refs, r => r.Path == "raw/2026-09-24-new.md"); + Assert.Equal("---\ntitle: New\n---\n\nText.\n", await store.ReadAsync(created, head.Commit, CancellationToken.None)); + + await Assert.ThrowsAsync(() => + store.CreateAsync("raw/2026-09-24-new.md", "other", "raw: New", CancellationToken.None)); + await Assert.ThrowsAsync(() => + store.CreateAsync("wiki/udm-dns.md", "other", "x", CancellationToken.None)); + } + [Fact] public async Task HealthReportsOk() { diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index 1a74703..d6b9827 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -32,8 +32,6 @@ public sealed class WikiHostFactory : WebApplicationFactory { services.RemoveAll(); services.AddSingleton(Store); - services.RemoveAll(); - services.AddSingleton(new FakeFactSink()); // The embedding model registered under the active name is replaced by a deterministic fake. services.RemoveAll(typeof(IEmbeddingModel)); var fake = new FakeEmbeddings("bge-m3"); diff --git a/test/Wiki.Host.Tests/Fakes.cs b/test/Wiki.Host.Tests/Fakes.cs index 764cba8..cf29fcf 100644 --- a/test/Wiki.Host.Tests/Fakes.cs +++ b/test/Wiki.Host.Tests/Fakes.cs @@ -33,14 +33,14 @@ public sealed class FakeStore : INoteStore } public Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct) => Task.FromResult(Files[reference.Path]); - public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); -} -public sealed class FakeFactSink : IFactSink -{ - public string Name => "fake-facts"; - public Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct) => - Task.FromResult(new FactReceipt("raw/fake.md", "c", now)); + public Task CreateAsync(string path, string text, string message, CancellationToken ct) + { + if (Files.ContainsKey(path)) throw new DocumentExistsException(path); + Files[path] = text; + return Task.FromResult(new StoreWrite(path, "c")); + } + public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); } -- 2.54.0