mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 16:27:21 +00:00
Added a way to read what the AI received from your sources (#1100)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
e0d8e18f34
commit
cbc4ce5413
229 files changed
+12739
-1269
No files matched your search
+43
@@ -0,0 +1,43 @@
|
||||
#nullable disable
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the table provenance, which holds what chats received besides their messages.
|
||||
/// </summary>
|
||||
[DbContext(typeof(ProvenanceStoreDbContext))]
|
||||
[Migration("20261024000000_InitialProvenanceStore")]
|
||||
public partial class InitialProvenanceStore : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "provenance",
|
||||
columns: table => new
|
||||
{
|
||||
chat_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
kind = table.Column<string>(type: "TEXT", nullable: false),
|
||||
content = table.Column<string>(type: "TEXT", nullable: false),
|
||||
surrounding_content = table.Column<string>(type: "TEXT", nullable: true),
|
||||
created_at_utc = table.Column<string>(type: "TEXT", nullable: false),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_provenance", entry => new { entry.chat_id, entry.id });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_provenance_id",
|
||||
table: "provenance",
|
||||
column: "id");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "provenance");
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#nullable disable
|
||||
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore.Migrations;
|
||||
|
||||
// EF Core writes this file and declares the type as partial. Dropping the keyword would only
|
||||
// last until the next migration is added, so the inspection is silenced instead.
|
||||
// ReSharper disable once PartialTypeWithSinglePart
|
||||
[DbContext(typeof(ProvenanceStoreDbContext))]
|
||||
partial class ProvenanceStoreDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.18");
|
||||
var utcDateTimeOffsetConverter = new IndexStoreDateTimeOffsetConverter();
|
||||
|
||||
modelBuilder.Entity("AIStudio.Tools.Databases.ProvenanceStore.ProvenanceEntity", entity =>
|
||||
{
|
||||
entity.Property<string>("ChatId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("chat_id");
|
||||
|
||||
entity.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
entity.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("content");
|
||||
|
||||
entity.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasConversion(utcDateTimeOffsetConverter)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
entity.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("kind");
|
||||
|
||||
entity.Property<string>("SurroundingContent")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("surrounding_content");
|
||||
|
||||
entity.HasKey("ChatId", "Id");
|
||||
|
||||
entity.HasIndex("Id")
|
||||
.HasDatabaseName("idx_provenance_id");
|
||||
|
||||
entity.ToTable("provenance");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the provenance store while it cannot serve: it keeps nothing and finds nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Storing answers with no id, which tells the caller to keep the content in the chat itself.
|
||||
/// </remarks>
|
||||
public sealed class NoProvenanceStoreClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : ProvenanceStoreClient(name, string.Empty)
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoProvenanceStoreClient).Namespace, nameof(NoProvenanceStoreClient));
|
||||
|
||||
public override DatabaseClientStatus Status => status;
|
||||
|
||||
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
|
||||
{
|
||||
yield return (TB("Status"), status switch
|
||||
{
|
||||
DatabaseClientStatus.STARTING => TB("Starting"),
|
||||
_ => TB("Unavailable")
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(unavailableReason))
|
||||
yield return (TB("Reason"), unavailableReason);
|
||||
|
||||
// As in NoIndexStoreClient: which library this process bound to matters most when SQLite cannot be loaded at all.
|
||||
yield return (TB("Native library"), OrUnknown(SqliteRuntimeInfo.GetNativeLibraryName()));
|
||||
yield return (TB("Process architecture"), OrUnknown(SqliteRuntimeInfo.GetProcessArchitecture()));
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
|
||||
|
||||
public override Task<Guid?> StoreAsync(Guid chatId, ProvenanceKind kind, string content, IReadOnlyList<string>? surroundingContent, CancellationToken token) => Task.FromResult<Guid?>(null);
|
||||
|
||||
public override Task<IReadOnlyDictionary<Guid, ProvenanceEntry>> GetEntriesAsync(IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.FromResult<IReadOnlyDictionary<Guid, ProvenanceEntry>>(new Dictionary<Guid, ProvenanceEntry>());
|
||||
|
||||
public override Task CopyChatAsync(Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.CompletedTask;
|
||||
|
||||
public override Task<int> DeleteChatsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.FromResult(0);
|
||||
|
||||
public override Task<int> DeleteChatEntriesExceptAsync(Guid chatId, IReadOnlyCollection<Guid> keptIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.FromResult(0);
|
||||
|
||||
public override Task<int> DeleteEntriesAsync(Guid chatId, IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.FromResult(0);
|
||||
|
||||
public override Task<IReadOnlyList<Guid>> GetChatIdsAsync(CancellationToken token, DateTimeOffset? createdSince = null) => Task.FromResult<IReadOnlyList<Guid>>([]);
|
||||
|
||||
public override Task<DateTimeOffset?> GetLastCleanupAsync(CancellationToken token) => Task.FromResult<DateTimeOffset?>(null);
|
||||
|
||||
public override Task SetLastCleanupAsync(DateTimeOffset startedAt, CancellationToken token) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Unlike the other methods, this one does not pretend success: a backup would take an empty
|
||||
/// copy for the real one. Whoever makes the backup decides what to do instead.
|
||||
/// </remarks>
|
||||
public override Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token) =>
|
||||
Task.FromException<string>(new InvalidOperationException(unavailableReason ?? "The provenance store is not available."));
|
||||
|
||||
public override Task<int> ImportChatAsync(string sourceDatabasePath, Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.FromResult(0);
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the table provenance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The key is the chat together with the id. A chat which is copied, e.g., an assistant's
|
||||
/// conversation which the user carries over into a chat, takes its rows along under the same ids,
|
||||
/// so its references stay valid, and deleting one of the two chats leaves the other one whole.
|
||||
/// </remarks>
|
||||
internal sealed class ProvenanceEntity
|
||||
{
|
||||
public string ChatId { get; set; } = string.Empty;
|
||||
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The ProvenanceKind, stored by name.
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The content around an ERI passage as a JSON array of strings, or null when there is none.
|
||||
/// </summary>
|
||||
public string? SurroundingContent { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
/// <summary>
|
||||
/// One piece of content a chat received besides its messages, exactly as the AI received it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Where the content comes from, such as the file, the page, or the tool, is kept by the chat
|
||||
/// itself, next to the id of this entry. The chat needs it to list its sources even without this
|
||||
/// store, and it would only be stored twice here.
|
||||
/// </remarks>
|
||||
/// <param name="Id">The id the chat refers to the entry by.</param>
|
||||
/// <param name="Kind">What sort of content this is.</param>
|
||||
/// <param name="Content">The content, after the prompt injection filter.</param>
|
||||
/// <param name="SurroundingContent">The content an ERI server sent along around its passage, empty for everything else.</param>
|
||||
/// <param name="CreatedAtUtc">When the entry was stored.</param>
|
||||
public sealed record ProvenanceEntry(Guid Id, ProvenanceKind Kind, string Content, IReadOnlyList<string> SurroundingContent, DateTimeOffset CreatedAtUtc);
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
/// <summary>
|
||||
/// What sort of content a chat received, apart from its messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stored by name, like the kinds of mail parts in the index: a new member may then go anywhere
|
||||
/// in this list, and the database reads on its own.
|
||||
/// </remarks>
|
||||
public enum ProvenanceKind
|
||||
{
|
||||
/// <summary>
|
||||
/// The content of a file the user attached to a message.
|
||||
/// </summary>
|
||||
ATTACHMENT,
|
||||
|
||||
/// <summary>
|
||||
/// A passage a data source handed to the AI: a local one or an ERI server, through the classic
|
||||
/// process or the semantic search.
|
||||
/// </summary>
|
||||
DATA_SOURCE_PASSAGE,
|
||||
|
||||
/// <summary>
|
||||
/// A mail, or a part of one, which the AI received from a mailbox.
|
||||
/// </summary>
|
||||
MAIL,
|
||||
|
||||
/// <summary>
|
||||
/// A web page the AI received, e.g., which a tool read for it.
|
||||
/// </summary>
|
||||
WEB_PAGE,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps what chats received besides their messages: the content of attached files, the results
|
||||
/// of tools, and the passages of data sources, each exactly as the AI received it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A chat keeps only the ids of its entries, next to where each one came from. The content lives
|
||||
/// here, so that a chat file stays small and an attached file is read only once.<br/><br/>
|
||||
///
|
||||
/// A database of its own rather than a table of the index: the index of the data sources can be
|
||||
/// rebuilt from the files and is treated that way, e.g., a backup may leave it out. What a chat
|
||||
/// received cannot be rebuilt, since the files, pages and mails may have changed since.<br/><br/>
|
||||
///
|
||||
/// Every entry belongs to one chat. The same id may stand for an entry of several chats, after a
|
||||
/// chat was copied, and then it holds the same content in each.
|
||||
/// </remarks>
|
||||
public abstract class ProvenanceStoreClient(string name, string path) : DatabaseClient(name, path)
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores one piece of content a chat received.
|
||||
/// </summary>
|
||||
/// <param name="chatId">The chat which received the content.</param>
|
||||
/// <param name="kind">What sort of content it is.</param>
|
||||
/// <param name="content">The content, exactly as the AI received it.</param>
|
||||
/// <param name="surroundingContent">The content an ERI server sent along around its passage, or nothing.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The id of the new entry, or null when this store cannot keep anything right now.</returns>
|
||||
public abstract Task<Guid?> StoreAsync(Guid chatId, ProvenanceKind kind, string content, IReadOnlyList<string>? surroundingContent, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Reads entries by their ids.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By the ids alone, without the chat: the place which shows an entry seldom knows its chat,
|
||||
/// and every copy of an entry holds the same content.
|
||||
/// </remarks>
|
||||
/// <param name="ids">The ids to read.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The entries found, by their ids. An id without an entry is missing from it.</returns>
|
||||
public abstract Task<IReadOnlyDictionary<Guid, ProvenanceEntry>> GetEntriesAsync(IReadOnlyCollection<Guid> ids, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Copies every entry of a chat to another chat, keeping their ids.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The copy is what a chat made from another one refers to. Entries the target holds already
|
||||
/// stay as they are.
|
||||
/// </remarks>
|
||||
/// <param name="sourceChatId">The chat to copy from.</param>
|
||||
/// <param name="targetChatId">The chat to copy to.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
public abstract Task CopyChatAsync(Guid sourceChatId, Guid targetChatId, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes every entry of some chats.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All of them at once: deleting a workspace or cleaning up at the start may concern many chats.
|
||||
/// </remarks>
|
||||
/// <param name="chatIds">The chats.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <param name="createdBefore">Deletes only the entries stored before this moment, or all of them when null.</param>
|
||||
/// <returns>How many entries were deleted.</returns>
|
||||
public abstract Task<int> DeleteChatsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken token, DateTimeOffset? createdBefore = null);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the entries of a chat which it no longer refers to.
|
||||
/// </summary>
|
||||
/// <param name="chatId">The chat.</param>
|
||||
/// <param name="keptIds">The ids the chat still refers to.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <param name="createdBefore">Deletes only the entries stored before this moment, or all of them when null.</param>
|
||||
/// <returns>How many entries were deleted.</returns>
|
||||
public abstract Task<int> DeleteChatEntriesExceptAsync(Guid chatId, IReadOnlyCollection<Guid> keptIds, CancellationToken token, DateTimeOffset? createdBefore = null);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes some entries of a chat, e.g., texts which a preview kept for a message that went without them.
|
||||
/// </summary>
|
||||
/// <param name="chatId">The chat.</param>
|
||||
/// <param name="ids">The ids of the entries.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>How many entries were deleted.</returns>
|
||||
public abstract Task<int> DeleteEntriesAsync(Guid chatId, IReadOnlyCollection<Guid> ids, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Lists the chats this store holds entries of.
|
||||
/// </summary>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <param name="createdSince">Lists only the chats with an entry stored at or after this moment, or all of them when null.</param>
|
||||
/// <returns>The ids of the chats, Guid.Empty included when entries were stored for a chat without an id.</returns>
|
||||
public abstract Task<IReadOnlyList<Guid>> GetChatIdsAsync(CancellationToken token, DateTimeOffset? createdSince = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reads when the last complete cleanup of this store began, see ProvenanceCleanup.
|
||||
/// </summary>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The moment, or null when no cleanup was noted, e.g., for a store a restore brought back.</returns>
|
||||
public abstract Task<DateTimeOffset?> GetLastCleanupAsync(CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Notes when a complete cleanup of this store began, see ProvenanceCleanup.
|
||||
/// </summary>
|
||||
/// <param name="startedAt">The moment the cleanup began.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
public abstract Task SetLastCleanupAsync(DateTimeOffset startedAt, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a copy of this store into a directory, for a backup.
|
||||
/// </summary>
|
||||
/// <param name="targetDirectory">The directory of the copy. A copy written there before is replaced.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The path of the copy.</returns>
|
||||
public abstract Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the entries of one chat from a copy of this store, such as the one a backup holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The entries keep their ids, so the chat's references stay valid, also when the chat is added
|
||||
/// under another id. Entries the target chat holds already stay as they are.
|
||||
/// </remarks>
|
||||
/// <param name="sourceDatabasePath">The copy to read from. It is only read.</param>
|
||||
/// <param name="sourceChatId">The chat in the copy.</param>
|
||||
/// <param name="targetChatId">The chat in this store.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>How many entries were added.</returns>
|
||||
public abstract Task<int> ImportChatAsync(string sourceDatabasePath, Guid sourceChatId, Guid targetChatId, CancellationToken token);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
internal sealed class ProvenanceStoreDbContext(DbContextOptions<ProvenanceStoreDbContext> options) : DbContext(options)
|
||||
{
|
||||
private static readonly SecureDeleteInterceptor SECURE_DELETE = new();
|
||||
|
||||
public static DbContextOptions<ProvenanceStoreDbContext> CreateOptions(string databasePath) => new DbContextOptionsBuilder<ProvenanceStoreDbContext>()
|
||||
.UseSqlite(BuildConnectionString(databasePath))
|
||||
.AddInterceptors(SECURE_DELETE)
|
||||
.Options;
|
||||
|
||||
public DbSet<ProvenanceEntity> Entries => this.Set<ProvenanceEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ProvenanceEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("provenance");
|
||||
entity.HasKey(entry => new { entry.ChatId, entry.Id });
|
||||
|
||||
entity.Property(entry => entry.ChatId).HasColumnName("chat_id");
|
||||
entity.Property(entry => entry.Id).HasColumnName("id");
|
||||
entity.Property(entry => entry.Kind).HasColumnName("kind").IsRequired();
|
||||
entity.Property(entry => entry.Content).HasColumnName("content").IsRequired();
|
||||
entity.Property(entry => entry.SurroundingContent).HasColumnName("surrounding_content");
|
||||
entity.Property(entry => entry.CreatedAtUtc).HasColumnName("created_at_utc").HasConversion(new IndexStoreDateTimeOffsetConverter()).IsRequired();
|
||||
|
||||
// A chat reads its entries by their ids alone, see ProvenanceStoreClient.GetEntriesAsync:
|
||||
entity.HasIndex(entry => entry.Id).HasDatabaseName("idx_provenance_id");
|
||||
});
|
||||
}
|
||||
|
||||
private static string BuildConnectionString(string databasePath) => new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = databasePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
DefaultTimeout = 30,
|
||||
}.ToString();
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
internal sealed class ProvenanceStoreDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ProvenanceStoreDbContext>
|
||||
{
|
||||
public ProvenanceStoreDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var databasePath = args.FirstOrDefault(argument => argument.EndsWith(".sqlite3", StringComparison.OrdinalIgnoreCase));
|
||||
if (string.IsNullOrWhiteSpace(databasePath))
|
||||
databasePath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-provenance-design.sqlite3");
|
||||
|
||||
return new ProvenanceStoreDbContext(ProvenanceStoreDbContext.CreateOptions(databasePath));
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
internal static class ProvenanceStoreSchemaMigrator
|
||||
{
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.InitialProvenanceStore))]
|
||||
public static async Task MigrateAsync(ProvenanceStoreDbContext context, CancellationToken token)
|
||||
{
|
||||
await context.Database.MigrateAsync(token);
|
||||
await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Data.Common;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
/// <summary>
|
||||
/// Turns on secure_delete for every connection to the provenance store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SQLite only marks the space of a deleted row as free, and its bytes stay in the file until
|
||||
/// another row takes their place. The provenance store holds whole documents, mails, and web pages,
|
||||
/// so the text of a deleted chat would stay readable in the file, perhaps for months. With
|
||||
/// secure_delete, SQLite overwrites it with zeros while deleting. The write-ahead log keeps the old
|
||||
/// pages until its next checkpoint, which SQLite makes by itself.<br/><br/>
|
||||
///
|
||||
/// The setting lasts as long as its connection, hence it is made for every connection anew.
|
||||
/// </remarks>
|
||||
internal sealed class SecureDeleteInterceptor : DbConnectionInterceptor
|
||||
{
|
||||
private const string PRAGMA = "PRAGMA secure_delete = ON;";
|
||||
|
||||
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = PRAGMA;
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = PRAGMA;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AIStudio.Tools.Databases.ProvenanceStore;
|
||||
|
||||
/// <summary>
|
||||
/// The provenance store in SQLite, see ProvenanceStoreClient.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every reading and writing of entries runs on a thread of its own. SQLite works synchronously
|
||||
/// behind its async methods, cf. the Databases AGENTS.md, and nearly everything this store holds is
|
||||
/// a whole document, a web page, or a mail. Many callers sit in the user interface, e.g., a preview
|
||||
/// which keeps its text, or a click which deletes a chat, and would freeze the window meanwhile.
|
||||
/// </remarks>
|
||||
public sealed class SqliteProvenanceStoreClientImplementation(string name, string databasePath, string basePath, string version) : ProvenanceStoreClient(name, basePath)
|
||||
{
|
||||
private const string DATABASE_NAME = "SQLite";
|
||||
|
||||
/// <summary>
|
||||
/// The file of the store, in its folder in the data folder and in a backup alike.
|
||||
/// </summary>
|
||||
public const string DATABASE_FILENAME = "provenance.sqlite3";
|
||||
|
||||
/// <summary>
|
||||
/// The file which notes when the last complete cleanup began, see GetLastCleanupAsync.
|
||||
/// </summary>
|
||||
private const string LAST_CLEANUP_FILENAME = "last-cleanup.txt";
|
||||
|
||||
/// <summary>
|
||||
/// Writes the content around a passage the way it reads: the default encoder would turn every
|
||||
/// character outside ASCII into an escape, cf. ToolExecutionResult.MODEL_CONTENT_OPTIONS.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JSON_OPTIONS = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
|
||||
private readonly string databasePath = databasePath;
|
||||
private readonly string lastCleanupPath = Path.Join(basePath, LAST_CLEANUP_FILENAME);
|
||||
private readonly DbContextOptions<ProvenanceStoreDbContext> dbContextOptions = ProvenanceStoreDbContext.CreateOptions(databasePath);
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SqliteProvenanceStoreClientImplementation).Namespace, nameof(SqliteProvenanceStoreClientImplementation));
|
||||
|
||||
public override string CacheKey => $"{this.Name}:{this.databasePath}:{version}";
|
||||
|
||||
public override string Version => version;
|
||||
|
||||
public static async Task<DatabaseClient> CreateAsync(
|
||||
ILogger logger,
|
||||
ILogger<DatabaseClient> databaseClientLogger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SettingsManager.DataDirectory))
|
||||
return CreateNoProvenanceStoreClient(DATABASE_NAME, "The application data directory is not available yet.", DatabaseClientStatus.STARTING, databaseClientLogger);
|
||||
|
||||
try
|
||||
{
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
|
||||
var basePath = Path.Combine(SettingsManager.DataDirectory, "databases", "provenance");
|
||||
Directory.CreateDirectory(basePath);
|
||||
|
||||
var databasePath = Path.Combine(basePath, DATABASE_FILENAME);
|
||||
var client = new SqliteProvenanceStoreClientImplementation(DATABASE_NAME, databasePath, basePath, string.Empty);
|
||||
await client.InitializeAsync(cancellationToken);
|
||||
var version = await client.GetSqliteVersionAsync(cancellationToken);
|
||||
|
||||
client = new SqliteProvenanceStoreClientImplementation(DATABASE_NAME, databasePath, basePath, version);
|
||||
client.SetLogger(databaseClientLogger);
|
||||
return client;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "The provenance store ({DatabaseName}) is not available. Chats keep what they received in their own files.", DATABASE_NAME);
|
||||
return CreateNoProvenanceStoreClient(DATABASE_NAME, exception.Message, DatabaseClientStatus.UNAVAILABLE, databaseClientLogger);
|
||||
}
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
|
||||
{
|
||||
//
|
||||
// Everything is read before the first line, since a yield return cannot stand in a try
|
||||
// block. Each probe catches its own failure, cf. the Databases AGENTS.md:
|
||||
//
|
||||
var schemaVersion = string.Empty;
|
||||
var journalMode = string.Empty;
|
||||
IReadOnlyDictionary<string, int>? countsByKind = null;
|
||||
int? chatCount = null;
|
||||
try
|
||||
{
|
||||
await using var context = this.CreateContext();
|
||||
schemaVersion = await GetSchemaVersionAsync(context);
|
||||
journalMode = (await QueryScalarTextAsync(context, "PRAGMA journal_mode;")).ToUpperInvariant();
|
||||
countsByKind = await TryCountByKindAsync(context);
|
||||
chatCount = await TryCountAsync(context.Entries.Select(entry => entry.ChatId).Distinct());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.Logger?.LogWarning(exception, "Failed to read the display details of the provenance store.");
|
||||
}
|
||||
|
||||
yield return (TB("Reported version"), version);
|
||||
yield return (TB("Journal mode"), OrUnknown(journalMode));
|
||||
yield return (TB("Schema version"), OrUnknown(schemaVersion));
|
||||
yield return (TB("Storage size"), this.GetStorageSize());
|
||||
|
||||
// The total counts an entry of a kind this version does not know as well, e.g., one written by a newer version:
|
||||
yield return (TB("Stored items across all chats"), countsByKind?.Values.Sum().CompactCount() ?? TB("unknown"));
|
||||
yield return (TB("Chats with stored items"), chatCount?.CompactCount() ?? TB("unknown"));
|
||||
|
||||
// One line per kind which any chat received, in the order of the kinds. A kind no chat received would only say zero:
|
||||
if (countsByKind is null)
|
||||
yield break;
|
||||
|
||||
foreach (var kind in Enum.GetValues<ProvenanceKind>())
|
||||
if (countsByKind.TryGetValue(kind.ToString(), out var count) && count > 0)
|
||||
yield return (GetKindLabel(kind), count.CompactCount());
|
||||
}
|
||||
|
||||
private static string GetKindLabel(ProvenanceKind kind) => kind switch
|
||||
{
|
||||
ProvenanceKind.ATTACHMENT => TB("Attached files"),
|
||||
ProvenanceKind.DATA_SOURCE_PASSAGE => TB("Passages of data sources"),
|
||||
ProvenanceKind.MAIL => TB("Mails"),
|
||||
ProvenanceKind.WEB_PAGE => TB("Web pages"),
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
public override Task<Guid?> StoreAsync(Guid chatId, ProvenanceKind kind, string content, IReadOnlyList<string>? surroundingContent, CancellationToken token) => Task.Run<Guid?>(async () =>
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
await using var context = this.CreateContext();
|
||||
context.Entries.Add(new ProvenanceEntity
|
||||
{
|
||||
ChatId = ToKey(chatId),
|
||||
Id = ToKey(id),
|
||||
Kind = kind.ToString(),
|
||||
Content = content,
|
||||
SurroundingContent = surroundingContent is { Count: > 0 } ? JsonSerializer.Serialize(surroundingContent, JSON_OPTIONS) : null,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync(token);
|
||||
return id;
|
||||
}, token);
|
||||
|
||||
public override Task<IReadOnlyDictionary<Guid, ProvenanceEntry>> GetEntriesAsync(IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.Run<IReadOnlyDictionary<Guid, ProvenanceEntry>>(async () =>
|
||||
{
|
||||
var entries = new Dictionary<Guid, ProvenanceEntry>();
|
||||
if (ids.Count is 0)
|
||||
return entries;
|
||||
|
||||
var keys = ids.Select(ToKey).Distinct().ToList();
|
||||
await using var context = this.CreateContext();
|
||||
var rows = await context.Entries
|
||||
.AsNoTracking()
|
||||
.Where(entry => keys.Contains(entry.Id))
|
||||
.ToListAsync(token);
|
||||
|
||||
// Copies of an entry hold the same content, so the first one found stands for all of them:
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!Guid.TryParse(row.Id, out var id) || entries.ContainsKey(id))
|
||||
continue;
|
||||
|
||||
if (!Enum.TryParse<ProvenanceKind>(row.Kind, out var kind))
|
||||
{
|
||||
this.Logger?.LogWarning("The provenance entry '{EntryId}' has the unknown kind '{Kind}', e.g., from a newer version of AI Studio, and is left out.", row.Id, row.Kind);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[id] = new ProvenanceEntry(id, kind, row.Content, this.ReadSurroundingContent(row), row.CreatedAtUtc);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, token);
|
||||
|
||||
public override Task CopyChatAsync(Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.Run(async () =>
|
||||
{
|
||||
var sourceKey = ToKey(sourceChatId);
|
||||
var targetKey = ToKey(targetChatId);
|
||||
await using var context = this.CreateContext();
|
||||
|
||||
//
|
||||
// In one statement, so no row passes through this process. The time of each row stays,
|
||||
// since it says when the AI received the content, not when the chat was copied:
|
||||
//
|
||||
await context.Database.ExecuteSqlAsync(
|
||||
$"""
|
||||
INSERT OR IGNORE INTO provenance (chat_id, id, kind, content, surrounding_content, created_at_utc)
|
||||
SELECT {targetKey}, id, kind, content, surrounding_content, created_at_utc
|
||||
FROM provenance
|
||||
WHERE chat_id = {sourceKey}
|
||||
""",
|
||||
token);
|
||||
}, token);
|
||||
|
||||
public override Task<int> DeleteChatsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.Run(async () =>
|
||||
{
|
||||
if (chatIds.Count is 0)
|
||||
return 0;
|
||||
|
||||
var chatKeys = chatIds.Select(ToKey).Distinct().ToList();
|
||||
await using var context = this.CreateContext();
|
||||
return await CreatedBefore(context.Entries.Where(entry => chatKeys.Contains(entry.ChatId)), createdBefore).ExecuteDeleteAsync(token);
|
||||
}, token);
|
||||
|
||||
public override Task<int> DeleteChatEntriesExceptAsync(Guid chatId, IReadOnlyCollection<Guid> keptIds, CancellationToken token, DateTimeOffset? createdBefore = null) => Task.Run(async () =>
|
||||
{
|
||||
var chatKey = ToKey(chatId);
|
||||
var keptKeys = keptIds.Select(ToKey).Distinct().ToList();
|
||||
await using var context = this.CreateContext();
|
||||
return await CreatedBefore(context.Entries.Where(entry => entry.ChatId == chatKey && !keptKeys.Contains(entry.Id)), createdBefore).ExecuteDeleteAsync(token);
|
||||
}, token);
|
||||
|
||||
public override Task<int> DeleteEntriesAsync(Guid chatId, IReadOnlyCollection<Guid> ids, CancellationToken token) => Task.Run(async () =>
|
||||
{
|
||||
if (ids.Count is 0)
|
||||
return 0;
|
||||
|
||||
var chatKey = ToKey(chatId);
|
||||
var keys = ids.Select(ToKey).Distinct().ToList();
|
||||
await using var context = this.CreateContext();
|
||||
return await context.Entries.Where(entry => entry.ChatId == chatKey && keys.Contains(entry.Id)).ExecuteDeleteAsync(token);
|
||||
}, token);
|
||||
|
||||
/// <summary>
|
||||
/// Narrows entries down to those stored before a moment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The time is stored as text in the round-trip format of UTC, whose order is the order of time,
|
||||
/// so SQLite compares it as text, after the parameter went through the same conversion.
|
||||
/// </remarks>
|
||||
private static IQueryable<ProvenanceEntity> CreatedBefore(IQueryable<ProvenanceEntity> entries, DateTimeOffset? createdBefore) => createdBefore is { } moment
|
||||
? entries.Where(entry => entry.CreatedAtUtc < moment)
|
||||
: entries;
|
||||
|
||||
public override Task<IReadOnlyList<Guid>> GetChatIdsAsync(CancellationToken token, DateTimeOffset? createdSince = null) => Task.Run<IReadOnlyList<Guid>>(async () =>
|
||||
{
|
||||
await using var context = this.CreateContext();
|
||||
var entries = createdSince is { } moment ? context.Entries.Where(entry => entry.CreatedAtUtc >= moment) : context.Entries;
|
||||
var chatKeys = await entries.Select(entry => entry.ChatId).Distinct().ToListAsync(token);
|
||||
|
||||
// Guid.Empty included: entries stored for a chat without an id belong to no chat on the disk, so the cleanup takes them:
|
||||
return chatKeys
|
||||
.Select(chatKey => Guid.TryParse(chatKey, out var chatId) ? chatId : (Guid?)null)
|
||||
.OfType<Guid>()
|
||||
.ToList();
|
||||
}, token);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Kept in a file next to the database rather than in it: the copy of the store which a backup
|
||||
/// holds comes without it, so the first cleanup of a store brought back from a backup reads every chat.
|
||||
/// </remarks>
|
||||
public override async Task<DateTimeOffset?> GetLastCleanupAsync(CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(this.lastCleanupPath))
|
||||
return null;
|
||||
|
||||
// A file which cannot be read costs one cleanup which reads every chat, and then it is written anew:
|
||||
try
|
||||
{
|
||||
var text = await File.ReadAllTextAsync(this.lastCleanupPath, token);
|
||||
return DateTimeOffset.TryParse(text.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var moment) ? moment : null;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
this.Logger?.LogWarning(exception, "Failed to read when the provenance store was cleaned up last.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override Task SetLastCleanupAsync(DateTimeOffset startedAt, CancellationToken token) => File.WriteAllTextAsync(this.lastCleanupPath, IndexStoreDateTimeOffset.ToUtcText(startedAt), token);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A consistent snapshot which holds back no writer, made on a thread of its own, cf. SqliteDatabaseCopy.
|
||||
/// </remarks>
|
||||
public override Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token) => Task.Run(async () =>
|
||||
{
|
||||
var targetPath = Path.Join(targetDirectory, DATABASE_FILENAME);
|
||||
await SqliteDatabaseCopy.CopyAndCheckAsync(this.databasePath, targetPath, token);
|
||||
return targetPath;
|
||||
}, token);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The copy is read on a connection of its own, read-only and without a pool, so nothing keeps it
|
||||
/// open afterward and the caller can delete it. Only the columns this version knows are read: a
|
||||
/// copy made by a newer version may hold more.<br/><br/>
|
||||
///
|
||||
/// The entries pass one at a time, since each one may be a whole document and a chat may hold
|
||||
/// many of them. All of them go in one transaction, so a chat arrives with all of its entries
|
||||
/// or with none. The time of each entry stays, as in CopyChatAsync.
|
||||
/// </remarks>
|
||||
public override Task<int> ImportChatAsync(string sourceDatabasePath, Guid sourceChatId, Guid targetChatId, CancellationToken token) => Task.Run(async () =>
|
||||
{
|
||||
var targetKey = ToKey(targetChatId);
|
||||
await using var source = new SqliteConnection(SqliteDatabaseCopy.BuildConnectionString(sourceDatabasePath, SqliteOpenMode.ReadOnly));
|
||||
await source.OpenAsync(token);
|
||||
await using var command = source.CreateCommand();
|
||||
command.CommandText = "SELECT id, kind, content, surrounding_content, created_at_utc FROM provenance WHERE chat_id = $chatId;";
|
||||
command.Parameters.AddWithValue("$chatId", ToKey(sourceChatId));
|
||||
|
||||
await using var context = this.CreateContext();
|
||||
await using var transaction = await context.Database.BeginTransactionAsync(token);
|
||||
await using var reader = await command.ExecuteReaderAsync(token);
|
||||
|
||||
var added = 0;
|
||||
while (await reader.ReadAsync(token))
|
||||
{
|
||||
var id = reader.GetString(0);
|
||||
var kind = reader.GetString(1);
|
||||
var content = reader.GetString(2);
|
||||
var surroundingContent = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||
var createdAtUtc = reader.GetString(4);
|
||||
|
||||
// Entries the chat holds already stay as they are:
|
||||
added += await context.Database.ExecuteSqlAsync(
|
||||
$"""
|
||||
INSERT OR IGNORE INTO provenance (chat_id, id, kind, content, surrounding_content, created_at_utc)
|
||||
VALUES ({targetKey}, {id}, {kind}, {content}, {surroundingContent}, {createdAtUtc})
|
||||
""",
|
||||
token);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(token);
|
||||
return added;
|
||||
}, token);
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an id the way this store keeps it: as text, lowercase and with hyphens.
|
||||
/// </summary>
|
||||
private static string ToKey(Guid id) => id.ToString("D");
|
||||
|
||||
private IReadOnlyList<string> ReadSurroundingContent(ProvenanceEntity row)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.SurroundingContent))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(row.SurroundingContent) ?? [];
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
this.Logger?.LogWarning(exception, "The content around the provenance entry '{EntryId}' cannot be read and is left out.", row.Id);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
|
||||
|
||||
private static async Task<string> QueryScalarTextAsync(ProvenanceStoreDbContext context, string sql)
|
||||
{
|
||||
try
|
||||
{
|
||||
// The raw connection rather than SqlQueryRaw, which cannot run a PRAGMA, cf. the Databases AGENTS.md:
|
||||
var connection = context.Database.GetDbConnection();
|
||||
if (connection.State is not ConnectionState.Open)
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
return result?.ToString() ?? string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> GetSchemaVersionAsync(ProvenanceStoreDbContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appliedMigrations = (await context.Database.GetAppliedMigrationsAsync()).ToList();
|
||||
if (appliedMigrations.Count == 0)
|
||||
return TB("no migration applied");
|
||||
|
||||
var pendingMigrations = (await context.Database.GetPendingMigrationsAsync()).ToList();
|
||||
return pendingMigrations.Count == 0
|
||||
? string.Format(I18N.I.Culture, TB("{0} ({1} applied)"), appliedMigrations[^1], appliedMigrations.Count.CompactCount())
|
||||
: string.Format(I18N.I.Culture, TB("{0} ({1} applied, {2} pending)"), appliedMigrations[^1], appliedMigrations.Count.CompactCount(), pendingMigrations.Count.CompactCount());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the entries of every kind, by the name each kind is stored with.
|
||||
/// </summary>
|
||||
/// <returns>The counts by kind, or null when the count failed.</returns>
|
||||
private static async Task<IReadOnlyDictionary<string, int>?> TryCountByKindAsync(ProvenanceStoreDbContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await context.Entries
|
||||
.GroupBy(entry => entry.Kind)
|
||||
.Select(group => new { Kind = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(group => group.Kind, group => group.Count);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int?> TryCountAsync<T>(IQueryable<T> query)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await query.CountAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeAsync(CancellationToken token)
|
||||
{
|
||||
await using var context = this.CreateContext();
|
||||
await ProvenanceStoreSchemaMigrator.MigrateAsync(context, token);
|
||||
}
|
||||
|
||||
private async Task<string> GetSqliteVersionAsync(CancellationToken token)
|
||||
{
|
||||
await using var context = this.CreateContext();
|
||||
var versions = await context.Database
|
||||
.SqlQueryRaw<string>("SELECT sqlite_version() AS Value")
|
||||
.ToListAsync(token);
|
||||
return versions.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
private ProvenanceStoreDbContext CreateContext() => new(this.dbContextOptions);
|
||||
|
||||
private static NoProvenanceStoreClient CreateNoProvenanceStoreClient(string name, string? unavailableReason, DatabaseClientStatus status, ILogger<DatabaseClient> databaseClientLogger)
|
||||
{
|
||||
var client = new NoProvenanceStoreClient(name, unavailableReason, status);
|
||||
client.SetLogger(databaseClientLogger);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user