< Summary

Information
Class: Elsa.UserTasks.Persistence.EFCore.Repositories.EFCoreUserTaskRepository
Assembly: Elsa.UserTasks.Persistence.EFCore
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs
Line coverage
76%
Covered lines: 449
Uncovered lines: 139
Coverable lines: 588
Total lines: 819
Line coverage: 76.3%
Branch coverage
74%
Covered branches: 210
Total branches: 282
Branch coverage: 74.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
GetAsync()100%44100%
QueryAsync()87.5%88100%
FindByMaterializationKeyAsync()100%44100%
FindByBookmarkIdAsync()100%44100%
FindByInvitationTokenHashAsync()83.33%6690%
SaveAsync()100%2282.35%
AddProjectionAsync()83.33%7675%
AppendEventAsync()100%44100%
TryMutateAsync()100%8888.88%
Elsa-UserTasks-Persistence-EFCore-Contracts-IUserTaskPersistenceAdapter-GetAsync()0%620%
QueryAsync()0%210140%
TryAddProjectionAsync()0%4260%
TrySaveAsync()0%4260%
BuildQuery(...)56.81%1324464.28%
ApplySafeSearch(...)100%22100%
ApplyOrdering(...)100%1818100%
ApplyCursor(...)96.87%323294.73%
CreateCursor(...)100%1212100%
TryReadCursor(...)50%6676.92%
LoadAggregateAsync()100%11100%
LoadAggregatesAsync()92.85%141493.75%
GroupByLoadedTask(...)100%44100%
ToModel(...)50%66100%
ToRecord(...)50%22100%
Copy(...)75%1616100%
Copy(...)100%210%
ReplaceChildrenAsync()81.25%161694.11%
ToCandidate(...)100%11100%
ToSnapshot(...)100%11100%
ToExclusion(...)100%11100%
ToParticipant(...)100%11100%
ToParticipant(...)100%11100%
ToParticipant(...)100%210%
ToParticipant(...)100%88100%
GetParticipantKey(...)100%11100%
ToEvent(...)100%22100%
ToEventRecord(...)100%88100%
ToOperation(...)66.66%6690.9%
ToOperationRecord(...)75%4491.66%
ToInvitation(...)50%22100%
ToInvitationRecord(...)50%22100%
ExistsByMaterializationKeyAsync()100%11100%
EnsureExpectedRevision(...)75%4480%
Serialize(...)100%11100%
Serialize(...)75%44100%
Deserialize(...)100%44100%
DeserializeJson(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskRepository.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Serialization;
 3using Elsa.Persistence.EFCore;
 4using Elsa.UserTasks.Contracts;
 5using Elsa.UserTasks.Models;
 6using Elsa.UserTasks.Persistence.EFCore.Contracts;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace Elsa.UserTasks.Persistence.EFCore.Repositories;
 10
 11/// <summary>
 12/// EF Core implementation of the public User Tasks repository and the low-level projection adapter.
 13/// Participant identities are flattened into provider/type/id columns; no identity or foreign-key
 14/// dependency is introduced by this package.
 15/// </summary>
 716public sealed class EFCoreUserTaskRepository(Store<UserTasksElsaDbContext, UserTaskRecord> store)
 17    : IUserTaskRepository, IUserTaskPersistenceAdapter
 18{
 219    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
 220    {
 221        Converters = { new JsonStringEnumConverter() }
 222    };
 23
 24    public async Task<UserTask?> GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
 25    {
 8426        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 8427        var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id ==
 8428        return record is null ? null : await LoadAggregateAsync(dbContext, record, cancellationToken);
 8429    }
 30
 31    public async Task<UserTaskQueryResult> QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default
 32    {
 14533        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 14534        var records = BuildQuery(dbContext, query);
 14535        int? totalCount = query.IncludeTotalCount ? await records.CountAsync(cancellationToken) : null;
 14536        var limit = Math.Clamp(query.Limit <= 0 ? 50 : query.Limit, 1, 200);
 14537        var rows = await ApplyCursor(ApplyOrdering(records, query), query).Take(limit + 1).ToListAsync(cancellationToken
 14538        var hasMore = rows.Count > limit;
 14539        var page = rows.Take(limit).ToList();
 40
 41        // The query predicate is intentionally SQL-side, but the policy layer still needs the
 42        // normalized candidate/snapshot/exclusion/history relationships to calculate allowed actions.
 43        // Hydrate those relationships in bounded batches after paging rather than issuing one query per
 44        // task or allowing a summary projection with empty candidate collections.
 14545        var items = await LoadAggregatesAsync(dbContext, page, cancellationToken);
 14546        return new UserTaskQueryResult(items, hasMore ? CreateCursor(page[^1], query.Sort) : null, totalCount);
 14547    }
 48
 49    public async Task<UserTask?> FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellati
 50    {
 251        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 252        var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Mater
 253        return record is null ? null : await LoadAggregateAsync(dbContext, record, cancellationToken);
 254    }
 55
 56    public async Task<UserTask?> FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellatio
 57    {
 258        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 259        var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Bookm
 260        return record is null ? null : await LoadAggregateAsync(dbContext, record, cancellationToken);
 261    }
 62
 63    public async Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, 
 64    {
 865        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 66        // The unique index on TokenHash makes this a single seek. Deliberately not tenant-filtered: an
 67        // anonymous holder presents only a secret and must not be trusted to name its own tenant.
 868        var row = await dbContext.UserTaskInvitations.AsNoTracking().FirstOrDefaultAsync(x => x.TokenHash == tokenHash, 
 869        if (row is null)
 270            return null;
 71
 672        var record = await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == row.TenantId && x.I
 673        if (record is null)
 074            return null;
 75
 676        var task = await LoadAggregateAsync(dbContext, record, cancellationToken);
 677        return (task, ToInvitation(row));
 878    }
 79
 80    public async Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default)
 81    {
 1982        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 1983        await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
 1984        var existing = await dbContext.UserTasks.FirstOrDefaultAsync(x => x.TenantId == task.TenantId && x.Id == task.Id
 1985        EnsureExpectedRevision(existing, task.Id, expectedRevision);
 86
 1687        Copy(task, existing!);
 1688        existing!.Revision = expectedRevision + 1;
 1689        existing.UpdatedAt = DateTimeOffset.UtcNow;
 1690        await ReplaceChildrenAsync(dbContext, task, cancellationToken);
 91
 92        try
 93        {
 1694            await dbContext.SaveChangesAsync(cancellationToken);
 1695            await transaction.CommitAsync(cancellationToken);
 1696            task.Revision = existing.Revision;
 1697            task.UpdatedAt = existing.UpdatedAt;
 1698        }
 099        catch (DbUpdateConcurrencyException exception)
 100        {
 0101            await transaction.RollbackAsync(cancellationToken);
 102            // Translated to the repository contract's exception so callers can distinguish a lost
 103            // optimistic-concurrency race from a fault without depending on EF Core.
 0104            throw new UserTaskRevisionConflictException(task.Id, expectedRevision, exception);
 105        }
 16106    }
 107
 108    public async Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default)
 109    {
 122110        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 122111        if (await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken))
 112            return;
 113
 121114        dbContext.UserTasks.Add(ToRecord(task));
 240115        dbContext.UserTaskCandidates.AddRange(task.CandidateUsers.Select(x => ToCandidate(task, x, UserTaskPersistenceCa
 122116        dbContext.UserTaskCandidates.AddRange(task.CandidateGroups.Select(x => ToCandidate(task, x, UserTaskPersistenceC
 126117        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotMembers.Select(x => ToSnapshot(task, x)));
 122118        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotGroups.Select(x => ToSnapshot(task, x)));
 124119        dbContext.UserTaskExclusions.AddRange(task.ExcludedUsers.Select(x => ToExclusion(task, x)));
 121120        dbContext.UserTaskEvents.AddRange(task.Events.Select(ToEventRecord));
 121121        dbContext.UserTaskOperations.AddRange(task.Operations.Select(ToOperationRecord));
 121122        dbContext.UserTaskInvitations.AddRange(task.Invitations.Select(ToInvitationRecord));
 123        try
 124        {
 121125            await dbContext.SaveChangesAsync(cancellationToken);
 121126        }
 0127        catch (DbUpdateException)
 128        {
 0129            if (!await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationTo
 0130                throw;
 131            // A concurrent projection won the unique materialization-key race. Projection is idempotent.
 132        }
 122133    }
 134
 135    public async Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellat
 136    {
 3137        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 3138        if (!await dbContext.UserTasks.AsNoTracking().AnyAsync(x => x.TenantId == tenantId && x.Id == taskId, cancellati
 139            return;
 140
 141        // A plain insert: the aggregate row, and therefore its revision, is deliberately left untouched.
 2142        dbContext.UserTaskEvents.Add(ToEventRecord(@event));
 2143        await dbContext.SaveChangesAsync(cancellationToken);
 3144    }
 145
 146    public async Task<bool> TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func<UserTask, bool> mu
 147    {
 11148        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 11149        await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
 11150        var record = await dbContext.UserTasks.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == taskId, cancel
 11151        if (record is null || record.Revision != expectedRevision)
 1152            return false;
 153
 10154        var task = await LoadAggregateAsync(dbContext, record, cancellationToken);
 10155        if (!mutation(task))
 1156            return false;
 157
 9158        Copy(task, record);
 9159        record.Revision = expectedRevision + 1;
 9160        record.UpdatedAt = DateTimeOffset.UtcNow;
 9161        await ReplaceChildrenAsync(dbContext, task, cancellationToken);
 162
 163        try
 164        {
 9165            await dbContext.SaveChangesAsync(cancellationToken);
 9166            await transaction.CommitAsync(cancellationToken);
 9167            return true;
 168        }
 169        catch (DbUpdateConcurrencyException)
 170        {
 0171            await transaction.RollbackAsync(cancellationToken);
 0172            return false;
 173        }
 11174    }
 175
 176    async Task<UserTaskRecord?> IUserTaskPersistenceAdapter.GetAsync(string tenantId, string taskId, CancellationToken c
 177    {
 0178        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 0179        return await dbContext.UserTasks.AsNoTracking().FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == taskI
 0180    }
 181
 182    public async Task<IReadOnlyCollection<UserTaskRecord>> QueryAsync(UserTaskPersistenceQuery query, CancellationToken 
 183    {
 0184        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 0185        var records = dbContext.UserTasks.AsNoTracking().Where(x => x.TenantId == query.TenantId);
 0186        if (query.Statuses.Count > 0)
 0187            records = records.Where(x => query.Statuses.Contains(x.Status));
 0188        if (!string.IsNullOrWhiteSpace(query.AssigneeProvider))
 0189            records = records.Where(x => x.AssigneeProvider == query.AssigneeProvider);
 0190        if (!string.IsNullOrWhiteSpace(query.AssigneeType))
 0191            records = records.Where(x => x.AssigneeType == query.AssigneeType);
 0192        if (!string.IsNullOrWhiteSpace(query.AssigneeId))
 0193            records = records.Where(x => x.AssigneeId == query.AssigneeId);
 0194        records = ApplySafeSearch(records, query.Search);
 195
 0196        var limit = query.Limit is > 0 ? Math.Min(query.Limit.Value, 200) : 100;
 0197        return await records.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenByDescending(x => x.Priority).ThenBy
 0198    }
 199
 200    public async Task<bool> TryAddProjectionAsync(UserTaskRecord task, CancellationToken cancellationToken = default)
 201    {
 0202        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 0203        if (await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken))
 0204            return false;
 0205        dbContext.UserTasks.Add(task);
 206        try
 207        {
 0208            await dbContext.SaveChangesAsync(cancellationToken);
 0209            return true;
 210        }
 0211        catch (DbUpdateException)
 212        {
 0213            if (!await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationTo
 0214                throw;
 0215            return false;
 216        }
 0217    }
 218
 219    public async Task<bool> TrySaveAsync(UserTaskRecord task, int expectedRevision, CancellationToken cancellationToken 
 220    {
 0221        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 0222        var existing = await dbContext.UserTasks.FirstOrDefaultAsync(x => x.TenantId == task.TenantId && x.Id == task.Id
 0223        if (existing is null || existing.Revision != expectedRevision)
 0224            return false;
 0225        Copy(task, existing);
 0226        existing.Revision = expectedRevision + 1;
 0227        existing.UpdatedAt = DateTimeOffset.UtcNow;
 228        try
 229        {
 0230            await dbContext.SaveChangesAsync(cancellationToken);
 0231            return true;
 232        }
 0233        catch (DbUpdateConcurrencyException)
 234        {
 0235            return false;
 236        }
 0237    }
 238
 239    private static IQueryable<UserTaskRecord> BuildQuery(UserTasksElsaDbContext dbContext, UserTaskQuery query)
 240    {
 145241        var records = dbContext.UserTasks.AsNoTracking();
 145242        records = records.Where(x => x.TenantId == query.TenantId);
 145243        if (query.Statuses.Count > 0)
 244        {
 0245            var statuses = query.Statuses.ToArray();
 0246            records = records.Where(x => statuses.Contains(x.Status));
 247        }
 145248        if (query.OnlyOverdue)
 0249            records = records.Where(x => x.IsOverdue);
 145250        if (query.OnlyWithoutDueDate)
 0251            records = records.Where(x => x.DueAt == null);
 145252        if (!string.IsNullOrWhiteSpace(query.TaskType))
 0253            records = records.Where(x => x.TaskType == query.TaskType);
 145254        if (query.PriorityFrom is not null)
 0255            records = records.Where(x => x.Priority >= query.PriorityFrom);
 145256        if (query.PriorityTo is not null)
 0257            records = records.Where(x => x.Priority <= query.PriorityTo);
 145258        if (query.DueFrom is not null)
 0259            records = records.Where(x => x.DueAt >= query.DueFrom);
 145260        if (query.DueTo is not null)
 0261            records = records.Where(x => x.DueAt <= query.DueTo);
 145262        if (!string.IsNullOrWhiteSpace(query.WorkflowDefinitionId))
 0263            records = records.Where(x => x.WorkflowDefinitionId == query.WorkflowDefinitionId);
 145264        if (!string.IsNullOrWhiteSpace(query.WorkflowInstanceId))
 0265            records = records.Where(x => x.WorkflowInstanceId == query.WorkflowInstanceId);
 145266        if (!string.IsNullOrWhiteSpace(query.Reference))
 0267            records = records.Where(x => x.Reference == query.Reference);
 145268        records = ApplySafeSearch(records, query.Search);
 145269        if (query.Scope is { } requestedScope &&
 145270            (!string.Equals(requestedScope.TenantId, query.TenantId, StringComparison.Ordinal) ||
 145271             !string.Equals(requestedScope.Subject.TenantId, query.TenantId, StringComparison.Ordinal) ||
 146272             requestedScope.Groups.Any(group => !string.Equals(group.TenantId, query.TenantId, StringComparison.Ordinal)
 2273            return records.Where(_ => false);
 274
 143275        if (query.Scope is not { } scope)
 0276            return records;
 277
 143278        var subject = scope.Subject;
 143279        var subjectType = subject.Type.ToString();
 143280        var groupKeys = scope.Groups.Select(GetParticipantKey).Distinct(StringComparer.Ordinal).ToArray();
 143281        var tenantId = query.TenantId;
 282
 143283        if (scope.ExcludeBlocking)
 143284            records = records.Where(task => task.HealthSeverity != UserTaskHealthSeverity.Blocking);
 285
 286        // Manager-only scopes were already rejected by the policy for non-managers. Reaching them here means
 287        // the caller manages the tenant, so the tenant predicate above is the whole authorization.
 143288        if (scope.RequiresManager)
 289        {
 0290            if (!scope.IsManager)
 0291                return records.Where(_ => false);
 0292            if (scope.Kind == UserTaskQueryScopeKind.NeedsAttention)
 293            {
 0294                records = records.Where(task =>
 0295                    task.HealthSeverity == UserTaskHealthSeverity.Blocking
 0296                    || task.IsOverdue
 0297                    || (task.AssigneeId == null && task.Status != UserTaskStatus.Completed && task.Status != UserTaskSta
 0298                    || task.Status == UserTaskStatus.Completing
 0299                    || task.Status == UserTaskStatus.TimingOut
 0300                    || task.Status == UserTaskStatus.Cancelling);
 301            }
 0302            return records;
 303        }
 304
 305        // Correlated predicates are applied in the query path so tenant, eligibility, and exclusion are
 306        // evaluated before totals, cursors, and page limits â€” an unauthorized row can never reach the page.
 143307        return scope.Kind switch
 143308        {
 1309            UserTaskQueryScopeKind.Assigned => records.Where(task =>
 1310                task.AssigneeProvider == subject.Provider && task.AssigneeType == subjectType && task.AssigneeId == subj
 143311
 143312            // Same rule as DefaultUserTaskAccessPolicy.IsCandidate / InMemory IsEligible:
 143313            // Snapshot uses expanded members only; Live uses current candidates. SnapshotGroups
 143314            // are the original group refs and must not re-open live group evaluation.
 142315            UserTaskQueryScopeKind.Available => records.Where(task =>
 142316                task.AssigneeId == null
 142317                && task.Status != UserTaskStatus.Completed && task.Status != UserTaskStatus.TimedOut && task.Status != U
 142318                && !dbContext.UserTaskExclusions.Any(exclusion =>
 142319                    exclusion.TenantId == tenantId && exclusion.TaskId == task.Id &&
 142320                    exclusion.ParticipantType == UserTaskParticipantType.User && exclusion.Provider == subject.Provider 
 142321                && (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot
 142322                    ? dbContext.UserTaskSnapshotMembers.Any(member =>
 142323                        member.TenantId == tenantId && member.TaskId == task.Id &&
 142324                        member.ParticipantType == UserTaskParticipantType.User && member.Provider == subject.Provider &&
 142325                    : dbContext.UserTaskCandidates.Any(candidate =>
 142326                            candidate.TenantId == tenantId && candidate.TaskId == task.Id &&
 142327                            candidate.ParticipantType == UserTaskParticipantType.User && candidate.Provider == subject.P
 142328                        || (groupKeys.Length > 0 && dbContext.UserTaskCandidates.Any(candidate =>
 142329                            candidate.TenantId == tenantId && candidate.TaskId == task.Id &&
 142330                            candidate.ParticipantType == UserTaskParticipantType.Group && groupKeys.Contains(candidate.P
 143331
 0332            UserTaskQueryScopeKind.History => records.Where(task =>
 0333                (task.Status == UserTaskStatus.Completed || task.Status == UserTaskStatus.TimedOut || task.Status == Use
 0334                && dbContext.UserTaskEvents.Any(@event =>
 0335                    @event.TenantId == tenantId && @event.TaskId == task.Id &&
 0336                    @event.ActorProvider == subject.Provider && @event.ActorType == subjectType && @event.ActorId == sub
 143337
 0338            _ => records.Where(_ => false)
 143339        };
 340    }
 341
 342    /// <summary>
 343    /// Safe search is a bounded text contains over title, summary, reference, task type, and tags.
 344    /// Tags stay in the existing <c>TagsJson</c> column as a primitive collection, so each tag is
 345    /// matched individually and case-insensitively â€” the same semantics as InMemory/VNext, with no
 346    /// tag table and no JSON-syntax false positives.
 347    /// </summary>
 348    private static IQueryable<UserTaskRecord> ApplySafeSearch(IQueryable<UserTaskRecord> records, string? search)
 349    {
 145350        if (string.IsNullOrWhiteSpace(search))
 141351            return records;
 352
 4353        var value = search.Trim();
 4354        var tag = value.ToLower();
 4355        return records.Where(x =>
 4356            x.Title.Contains(value)
 4357            || (x.Summary != null && x.Summary.Contains(value))
 4358            || (x.Reference != null && x.Reference.Contains(value))
 4359            || (x.TaskType != null && x.TaskType.Contains(value))
 4360            || x.Tags.Any(t => t.ToLower().Contains(tag)));
 361    }
 362
 363    private static IQueryable<UserTaskRecord> ApplyOrdering(IQueryable<UserTaskRecord> records, UserTaskQuery query)
 364    {
 365        // Id is always ThenBy ascending so a page of ties is the same in both directions and across providers.
 145366        return query.Sort.ToLowerInvariant() switch
 145367        {
 39368            "due" when query.Descending => records.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x
 13369            "due" => records.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenBy(x => x.Id),
 36370            "priority" when query.Descending => records.OrderByDescending(x => x.Priority).ThenBy(x => x.Id),
 12371            "priority" => records.OrderBy(x => x.Priority).ThenBy(x => x.Id),
 145372            // Title OrderBy and cursor use column collation (self-consistent). Title cursors are not
 145373            // portable to InMemory/VNext ordinal comparison; recreate the list after a provider change.
 36374            "title" when query.Descending => records.OrderByDescending(x => x.Title).ThenBy(x => x.Id),
 12375            "title" => records.OrderBy(x => x.Title).ThenBy(x => x.Id),
 39376            "updated" when query.Descending => records.OrderByDescending(x => x.UpdatedAt).ThenBy(x => x.Id),
 13377            "updated" => records.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id),
 57378            _ when query.Descending => records.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id),
 33379            _ => records.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id)
 145380        };
 381    }
 382
 383    private static IQueryable<UserTaskRecord> ApplyCursor(IQueryable<UserTaskRecord> records, UserTaskQuery query)
 384    {
 145385        if (string.IsNullOrWhiteSpace(query.Cursor) || !TryReadCursor(query.Cursor, out var cursorValue, out var cursorI
 63386            return records;
 387
 388        return query.Sort.ToLowerInvariant() switch
 389        {
 32390            "priority" when int.TryParse(cursorValue, out var priority) => query.Descending
 16391                ? records.Where(x => x.Priority < priority || (x.Priority == priority && string.Compare(x.Id, cursorId) 
 16392                : records.Where(x => x.Priority > priority || (x.Priority == priority && string.Compare(x.Id, cursorId) 
 16393            "title" => query.Descending
 16394                ? records.Where(x => string.Compare(x.Title, cursorValue) < 0 || (string.Compare(x.Title, cursorValue) =
 16395                : records.Where(x => string.Compare(x.Title, cursorValue) > 0 || (string.Compare(x.Title, cursorValue) =
 18396            "due" when cursorValue == "~null" => records.Where(x => x.DueAt == null && string.Compare(x.Id, cursorId) > 
 28397            "due" when DateTimeOffset.TryParse(cursorValue, out var dueAt) => query.Descending
 14398                ? records.Where(x => x.DueAt == null || x.DueAt < dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cur
 14399                : records.Where(x => x.DueAt == null || x.DueAt > dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cur
 32400            "updated" when DateTimeOffset.TryParse(cursorValue, out var updatedAt) => query.Descending
 16401                ? records.Where(x => x.UpdatedAt < updatedAt || (x.UpdatedAt == updatedAt && string.Compare(x.Id, cursor
 16402                : records.Where(x => x.UpdatedAt > updatedAt || (x.UpdatedAt == updatedAt && string.Compare(x.Id, cursor
 36403            _ when DateTimeOffset.TryParse(cursorValue, out var createdAt) => query.Descending
 18404                ? records.Where(x => x.CreatedAt < createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursor
 18405                : records.Where(x => x.CreatedAt > createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursor
 0406            _ => records
 407        };
 408    }
 409
 410    private static string CreateCursor(UserTaskRecord record, string sort)
 411    {
 83412        var value = sort.ToLowerInvariant() switch
 83413        {
 16414            "priority" => record.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
 16415            "title" => record.Title,
 16416            "due" => record.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null",
 16417            "updated" => record.UpdatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
 19418            _ => record.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
 83419        };
 83420        return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, record.Id }, JsonOptions))
 83421            .TrimEnd('=').Replace('+', '-').Replace('/', '_');
 422    }
 423
 424    private static bool TryReadCursor(string cursor, out string value, out string id)
 425    {
 83426        value = id = "";
 427        try
 428        {
 83429            var padded = cursor.Replace('-', '+').Replace('_', '/') + new string('=', (4 - cursor.Length % 4) % 4);
 83430            var parts = JsonSerializer.Deserialize<string[]>(Convert.FromBase64String(padded), JsonOptions);
 82431            if (parts is not [var cursorValue, var cursorId] || string.IsNullOrWhiteSpace(cursorId))
 0432                return false;
 82433            value = cursorValue;
 82434            id = cursorId;
 82435            return true;
 436        }
 0437        catch (FormatException)
 438        {
 0439            return false;
 440        }
 1441        catch (JsonException)
 442        {
 1443            return false;
 444        }
 83445    }
 446
 447    private static async Task<UserTask> LoadAggregateAsync(UserTasksElsaDbContext dbContext, UserTaskRecord record, Canc
 448    {
 99449        return (await LoadAggregatesAsync(dbContext, [record], cancellationToken))[0];
 99450    }
 451
 452    private static async Task<List<UserTask>> LoadAggregatesAsync(UserTasksElsaDbContext dbContext, IReadOnlyCollection<
 453    {
 244454        var tasks = records.Select(ToModel).ToList();
 244455        if (tasks.Count == 0)
 6456            return tasks;
 457
 238458        var tenantId = records.First().TenantId;
 629459        var taskIds = tasks.Select(x => x.Id).ToArray();
 629460        var taskById = tasks.ToDictionary(x => x.Id, StringComparer.Ordinal);
 238461        var candidates = await dbContext.UserTaskCandidates.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.
 1649462        foreach (var (task, group) in GroupByLoadedTask(candidates, x => x.TaskId, taskById))
 463        {
 782464            task.CandidateUsers = group.Where(x => x.ParticipantType == UserTaskParticipantType.User).Select(ToParticipa
 782465            task.CandidateGroups = group.Where(x => x.ParticipantType == UserTaskParticipantType.Group).Select(ToPartici
 466        }
 467
 238468        var snapshots = await dbContext.UserTaskSnapshotMembers.AsNoTracking().Where(x => x.TenantId == tenantId && task
 482469        foreach (var (task, group) in GroupByLoadedTask(snapshots, x => x.TaskId, taskById))
 470        {
 4471            task.SnapshotMembers = group.Where(x => x.ParticipantType == UserTaskParticipantType.User).Select(ToParticip
 4472            task.SnapshotGroups = group.Where(x => x.ParticipantType == UserTaskParticipantType.Group).Select(ToParticip
 473        }
 474
 238475        var exclusions = await dbContext.UserTaskExclusions.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.
 476476        foreach (var (task, group) in GroupByLoadedTask(exclusions, x => x.TaskId, taskById))
 0477            task.ExcludedUsers = group.Select(ToParticipant).ToList();
 478
 238479        var events = await dbContext.UserTaskEvents.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains
 665480        foreach (var (task, group) in GroupByLoadedTask(events, x => x.TaskId, taskById))
 49481            task.Events = group.Select(ToEvent).ToList();
 482
 238483        var operations = await dbContext.UserTaskOperations.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.
 500484        foreach (var (task, group) in GroupByLoadedTask(operations, x => x.TaskId, taskById))
 7485            task.Operations = group.Select(ToOperation).ToList();
 486
 238487        var invitations = await dbContext.UserTaskInvitations.AsNoTracking().Where(x => x.TenantId == tenantId && taskId
 602488        foreach (var (task, group) in GroupByLoadedTask(invitations, x => x.TaskId, taskById))
 42489            task.Invitations = group.Select(ToInvitation).ToList();
 490
 238491        return tasks;
 244492    }
 493
 494    /// <summary>
 495    /// Pairs each group of child rows with the task it belongs to, dropping groups whose task is not on the
 496    /// current page. The filter is explicit and each group still costs a single dictionary probe.
 497    /// </summary>
 498    private static IEnumerable<(UserTask Task, IGrouping<string, TRow> Rows)> GroupByLoadedTask<TRow>(
 499        IEnumerable<TRow> rows, Func<TRow, string> taskIdSelector, Dictionary<string, UserTask> taskById) =>
 1428500        rows.GroupBy(taskIdSelector)
 491501            .Select(group => (Task: taskById.GetValueOrDefault(group.Key), Rows: group))
 491502            .Where(pair => pair.Task is not null)
 1919503            .Select(pair => (pair.Task!, pair.Rows));
 504
 391505    private static UserTask ToModel(UserTaskRecord record) => new()
 391506    {
 391507        Id = record.Id,
 391508        TenantId = record.TenantId,
 391509        WorkflowDefinitionId = record.WorkflowDefinitionId,
 391510        WorkflowDefinitionName = record.WorkflowDefinitionName,
 391511        WorkflowDefinitionVersion = record.WorkflowDefinitionVersion,
 391512        WorkflowInstanceId = record.WorkflowInstanceId,
 391513        WorkflowInstanceReference = record.WorkflowInstanceReference,
 391514        ActivityInstanceId = record.ActivityInstanceId,
 391515        BookmarkId = record.BookmarkId,
 391516        MaterializationKey = record.MaterializationKey,
 391517        Title = record.Title,
 391518        Summary = record.Summary,
 391519        Reference = record.Reference,
 391520        Tags = new HashSet<string>(record.Tags ?? [], StringComparer.OrdinalIgnoreCase),
 391521        TaskType = record.TaskType,
 391522        Requester = ToParticipant(record.RequesterProvider, record.RequesterType, record.RequesterId, record.RequesterDi
 391523        Assignee = ToParticipant(record.AssigneeProvider, record.AssigneeType, record.AssigneeId, record.AssigneeDisplay
 391524        MembershipResolutionMode = record.MembershipResolutionMode ?? UserTaskMembershipResolutionMode.Live,
 391525        AllowManagerExclusionOverride = record.AllowManagerExclusionOverride,
 391526        Priority = record.Priority,
 391527        DueAt = record.DueAt,
 391528        IsOverdue = record.IsOverdue,
 391529        Instructions = Deserialize<string>(record.InstructionsJson),
 391530        TaskData = DeserializeJson(record.TaskDataJson),
 391531        RequestedForm = Deserialize<UserTaskFormReference>(record.FormReferenceJson),
 391532        PinnedForm = Deserialize<ResolvedUserTaskForm>(record.PinnedFormJson),
 391533        Actions = Deserialize<List<UserTaskAction>>(record.ActionsJson) ?? [new UserTaskAction("Complete", "Complete")],
 391534        InvitationDefinitions = Deserialize<List<UserTaskInvitationDefinition>>(record.InvitationDefinitionsJson) ?? [],
 391535        EnableTimeoutOutcome = record.TimeoutEnabled,
 391536        EnableCancellationOutcome = record.CancellationEnabled,
 391537        Status = record.Status,
 391538        HealthSeverity = record.HealthSeverity,
 391539        HealthCode = record.HealthCode,
 391540        HealthMessage = record.HealthMessage,
 391541        CompletionActionKey = record.CompletionActionKey,
 391542        CompletionData = DeserializeJson(record.CompletionDataJson),
 391543        CompletedBy = Deserialize<ParticipantReference>(record.CompletionActorJson),
 391544        CreatedAt = record.CreatedAt,
 391545        UpdatedAt = record.UpdatedAt,
 391546        AssignedAt = record.AssignedAt,
 391547        CompletedAt = record.CompletedAt,
 391548        Revision = record.Revision
 391549    };
 550
 551    private static UserTaskRecord ToRecord(UserTask task)
 552    {
 121553        var record = new UserTaskRecord { Id = string.IsNullOrWhiteSpace(task.Id) ? Guid.NewGuid().ToString("N") : task.
 121554        Copy(task, record);
 121555        return record;
 556    }
 557
 558    private static void Copy(UserTask source, UserTaskRecord target)
 559    {
 146560        target.Id = source.Id;
 146561        target.TenantId = source.TenantId;
 146562        target.WorkflowDefinitionId = source.WorkflowDefinitionId;
 146563        target.WorkflowDefinitionName = source.WorkflowDefinitionName;
 146564        target.WorkflowDefinitionVersion = source.WorkflowDefinitionVersion;
 146565        target.WorkflowInstanceId = source.WorkflowInstanceId;
 146566        target.WorkflowInstanceReference = source.WorkflowInstanceReference;
 146567        target.ActivityInstanceId = source.ActivityInstanceId;
 146568        target.BookmarkId = source.BookmarkId;
 146569        target.MaterializationKey = source.MaterializationKey;
 146570        target.Title = source.Title;
 146571        target.Summary = source.Summary;
 146572        target.Reference = source.Reference;
 146573        target.TaskType = source.TaskType;
 146574        target.Tags = source.Tags.ToList();
 146575        target.RequesterProvider = source.Requester?.Provider;
 146576        target.RequesterType = source.Requester?.Type.ToString();
 146577        target.RequesterId = source.Requester?.Id;
 146578        target.RequesterDisplayName = source.Requester?.DisplayName;
 146579        target.Priority = source.Priority;
 146580        target.DueAt = source.DueAt;
 146581        target.IsOverdue = source.IsOverdue;
 146582        target.Status = source.Status;
 146583        target.TimeoutEnabled = source.EnableTimeoutOutcome;
 146584        target.CancellationEnabled = source.EnableCancellationOutcome;
 146585        target.AllowManagerExclusionOverride = source.AllowManagerExclusionOverride;
 146586        target.MembershipResolutionMode = source.MembershipResolutionMode;
 146587        target.AssigneeProvider = source.Assignee?.Provider;
 146588        target.AssigneeType = source.Assignee?.Type.ToString();
 146589        target.AssigneeId = source.Assignee?.Id;
 146590        target.AssigneeDisplayName = source.Assignee?.DisplayName;
 146591        target.InstructionsJson = Serialize(source.Instructions);
 146592        target.TaskDataJson = Serialize(source.TaskData);
 146593        target.FormReferenceJson = Serialize(source.RequestedForm);
 146594        target.PinnedFormJson = Serialize(source.PinnedForm);
 146595        target.ActionsJson = Serialize(source.Actions);
 146596        target.InvitationDefinitionsJson = Serialize(source.InvitationDefinitions);
 146597        target.HealthIssuesJson = Serialize(new { source.HealthSeverity, source.HealthCode, source.HealthMessage });
 146598        target.HealthSeverity = source.HealthSeverity;
 146599        target.HealthCode = source.HealthCode;
 146600        target.HealthMessage = source.HealthMessage;
 146601        target.CompletionActionKey = source.CompletionActionKey;
 146602        target.CompletionDataJson = Serialize(source.CompletionData);
 146603        target.CompletionActorJson = Serialize(source.CompletedBy);
 146604        target.CreatedAt = source.CreatedAt;
 146605        target.UpdatedAt = source.UpdatedAt;
 146606        target.AssignedAt = source.AssignedAt;
 146607        target.CompletedAt = source.CompletedAt;
 146608        target.Revision = source.Revision;
 146609    }
 610
 611    private static void Copy(UserTaskRecord source, UserTaskRecord target)
 612    {
 0613        target.TenantId = source.TenantId;
 0614        target.WorkflowDefinitionId = source.WorkflowDefinitionId;
 0615        target.WorkflowDefinitionName = source.WorkflowDefinitionName;
 0616        target.WorkflowDefinitionVersion = source.WorkflowDefinitionVersion;
 0617        target.WorkflowInstanceId = source.WorkflowInstanceId;
 0618        target.WorkflowInstanceReference = source.WorkflowInstanceReference;
 0619        target.ActivityInstanceId = source.ActivityInstanceId;
 0620        target.BookmarkId = source.BookmarkId;
 0621        target.MaterializationKey = source.MaterializationKey;
 0622        target.Title = source.Title;
 0623        target.Summary = source.Summary;
 0624        target.Reference = source.Reference;
 0625        target.TaskType = source.TaskType;
 0626        target.Tags = [..source.Tags];
 0627        target.RequesterProvider = source.RequesterProvider;
 0628        target.RequesterType = source.RequesterType;
 0629        target.RequesterId = source.RequesterId;
 0630        target.RequesterDisplayName = source.RequesterDisplayName;
 0631        target.Priority = source.Priority;
 0632        target.DueAt = source.DueAt;
 0633        target.IsOverdue = source.IsOverdue;
 0634        target.Status = source.Status;
 0635        target.TimeoutEnabled = source.TimeoutEnabled;
 0636        target.CancellationEnabled = source.CancellationEnabled;
 0637        target.AllowManagerExclusionOverride = source.AllowManagerExclusionOverride;
 0638        target.MembershipResolutionMode = source.MembershipResolutionMode;
 0639        target.AssigneeProvider = source.AssigneeProvider;
 0640        target.AssigneeType = source.AssigneeType;
 0641        target.AssigneeId = source.AssigneeId;
 0642        target.AssigneeDisplayName = source.AssigneeDisplayName;
 0643        target.InstructionsJson = source.InstructionsJson;
 0644        target.TaskDataJson = source.TaskDataJson;
 0645        target.FormReferenceJson = source.FormReferenceJson;
 0646        target.PinnedFormJson = source.PinnedFormJson;
 0647        target.ActionsJson = source.ActionsJson;
 0648        target.InvitationDefinitionsJson = source.InvitationDefinitionsJson;
 0649        target.HealthIssuesJson = source.HealthIssuesJson;
 0650        target.HealthSeverity = source.HealthSeverity;
 0651        target.HealthCode = source.HealthCode;
 0652        target.HealthMessage = source.HealthMessage;
 0653        target.CompletionActionKey = source.CompletionActionKey;
 0654        target.CompletionDataJson = source.CompletionDataJson;
 0655        target.CompletionActorJson = source.CompletionActorJson;
 0656        target.CreatedAt = source.CreatedAt;
 0657        target.UpdatedAt = source.UpdatedAt;
 0658        target.AssignedAt = source.AssignedAt;
 0659        target.CompletedAt = source.CompletedAt;
 0660        target.CreatedFromBookmarkRevision = source.CreatedFromBookmarkRevision;
 0661    }
 662
 663    private static async Task ReplaceChildrenAsync(UserTasksElsaDbContext dbContext, UserTask task, CancellationToken ca
 664    {
 25665        dbContext.UserTaskCandidates.RemoveRange(await dbContext.UserTaskCandidates.Where(x => x.TenantId == task.Tenant
 25666        dbContext.UserTaskSnapshotMembers.RemoveRange(await dbContext.UserTaskSnapshotMembers.Where(x => x.TenantId == t
 25667        dbContext.UserTaskExclusions.RemoveRange(await dbContext.UserTaskExclusions.Where(x => x.TenantId == task.Tenant
 25668        var existingEvents = await dbContext.UserTaskEvents.Where(x => x.TenantId == task.TenantId && x.TaskId == task.I
 41669        var existingEventIds = existingEvents.Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
 41670        var existingEventRevisions = existingEvents.Select(x => x.Revision).ToHashSet();
 56671        dbContext.UserTaskEvents.AddRange(task.Events.Where(x => !existingEventIds.Contains(x.Id) && !existingEventRevis
 672
 25673        var existingOperations = await dbContext.UserTaskOperations.Where(x => x.TenantId == task.TenantId && x.TaskId =
 27674        var existingOperationIds = existingOperations.Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
 58675        foreach (var operation in task.Operations)
 676        {
 7677            var entity = existingOperations.FirstOrDefault(x => x.Id == operation.Id || x.OperationId == operation.Opera
 4678            if (entity is null)
 679            {
 2680                if (!existingOperationIds.Contains(operation.Id))
 2681                    dbContext.UserTaskOperations.Add(ToOperationRecord(operation));
 2682                continue;
 683            }
 684
 2685            entity.Kind = operation.Kind.ToString();
 2686            entity.ExpectedRevision = operation.ExpectedRevision;
 2687            entity.RequestHash = operation.RequestHash;
 2688            entity.Status = operation.Status switch
 2689            {
 2690                UserTaskOperationStatus.Completed => UserTaskPersistenceOperationStatus.Completed,
 0691                UserTaskOperationStatus.Failed => UserTaskPersistenceOperationStatus.Failed,
 0692                _ => UserTaskPersistenceOperationStatus.Enqueued
 2693            };
 2694            entity.UpdatedAt = operation.UpdatedAt;
 2695            entity.ActionKey = operation.ActionKey;
 2696            entity.ProtectedPayloadJson = Serialize(operation.Data);
 2697            entity.ErrorCode = operation.ErrorCode;
 698        }
 699
 25700        var existingInvitations = await dbContext.UserTaskInvitations.Where(x => x.TenantId == task.TenantId && x.TaskId
 33701        var existingInvitationIds = existingInvitations.Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
 74702        foreach (var invitation in task.Invitations)
 703        {
 20704            var entity = existingInvitations.FirstOrDefault(x => x.Id == invitation.Id);
 12705            if (entity is null)
 706            {
 4707                if (!existingInvitationIds.Contains(invitation.Id))
 4708                    dbContext.UserTaskInvitations.Add(ToInvitationRecord(invitation));
 4709                continue;
 710            }
 711
 8712            entity.Status = invitation.Status;
 8713            entity.VerifiedAt = invitation.VerifiedAt;
 8714            entity.ConsumedAt = invitation.ConsumedAt;
 8715            entity.RevokedAt = invitation.RevokedAt;
 716        }
 50717        dbContext.UserTaskCandidates.AddRange(task.CandidateUsers.Select(x => ToCandidate(task, x, UserTaskPersistenceCa
 25718        dbContext.UserTaskCandidates.AddRange(task.CandidateGroups.Select(x => ToCandidate(task, x, UserTaskPersistenceC
 25719        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotMembers.Select(x => ToSnapshot(task, x)));
 25720        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotGroups.Select(x => ToSnapshot(task, x)));
 25721        dbContext.UserTaskExclusions.AddRange(task.ExcludedUsers.Select(x => ToExclusion(task, x)));
 25722    }
 723
 145724    private static UserTaskCandidateRecord ToCandidate(UserTask task, ParticipantReference participant, UserTaskPersiste
 145725    {
 145726        TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(
 145727        ParticipantId = participant.Id, DisplayName = participant.DisplayName, Source = source
 145728    };
 729
 6730    private static UserTaskSnapshotMemberRecord ToSnapshot(UserTask task, ParticipantReference participant) => new()
 6731    {
 6732        TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(
 6733        ParticipantId = participant.Id, CreatedAt = task.CreatedAt
 6734    };
 735
 3736    private static UserTaskExclusionRecord ToExclusion(UserTask task, ParticipantReference participant) => new()
 3737    {
 3738        TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(
 3739        ParticipantId = participant.Id, CreatedAt = task.CreatedAt
 3740    };
 741
 391742    private static ParticipantReference ToParticipant(UserTaskCandidateRecord row) => new(row.TenantId, row.Provider, ro
 2743    private static ParticipantReference ToParticipant(UserTaskSnapshotMemberRecord row) => new(row.TenantId, row.Provide
 0744    private static ParticipantReference ToParticipant(UserTaskExclusionRecord row) => new(row.TenantId, row.Provider, ro
 745
 746    private static ParticipantReference? ToParticipant(string? provider, string? type, string? id, string? displayName, 
 747    {
 786748        if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(id) || !
 753749            return null;
 33750        return new ParticipantReference(tenantId, provider, participantType, id, displayName);
 751    }
 752
 155753    private static string GetParticipantKey(ParticipantReference participant) => $"{participant.Provider}|{participant.T
 754
 91755    private static UserTaskEvent ToEvent(UserTaskEventRecord row) => new(row.Id, row.TenantId, row.TaskId, row.Revision,
 756
 18757    private static UserTaskEventRecord ToEventRecord(UserTaskEvent value) => new()
 18758    {
 18759        Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, Revision = value.Revision, EventType = value.Ev
 18760        OccurredAt = value.OccurredAt, ActorProvider = value.Actor?.Provider, ActorType = value.Actor?.Type.ToString(), 
 18761        ActorJson = Serialize(value.Actor), OperationId = value.OperationId, Reason = value.Reason,
 18762        MetadataJson = Serialize(value.Metadata ?? new Dictionary<string, object?>())
 18763    };
 764
 10765    private static UserTaskOperation ToOperation(UserTaskOperationRecord row) => new(
 10766        row.Id, row.TenantId, row.TaskId, row.OperationId,
 10767        Enum.TryParse<UserTaskOperationKind>(row.Kind, true, out var kind) ? kind : UserTaskOperationKind.Claim,
 10768        row.ExpectedRevision, row.RequestHash,
 10769        row.Status switch
 10770        {
 6771            UserTaskPersistenceOperationStatus.Completed => UserTaskOperationStatus.Completed,
 0772            UserTaskPersistenceOperationStatus.Failed => UserTaskOperationStatus.Failed,
 4773            _ => UserTaskOperationStatus.Accepted
 10774        },
 10775        row.CreatedAt, row.UpdatedAt, row.ActionKey, DeserializeJson(row.ProtectedPayloadJson), row.ErrorCode);
 776
 3777    private static UserTaskOperationRecord ToOperationRecord(UserTaskOperation value) => new()
 3778    {
 3779        Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, OperationId = value.OperationId, Kind = value.K
 3780        ExpectedRevision = value.ExpectedRevision, RequestHash = value.RequestHash,
 3781        Status = value.Status switch
 3782        {
 1783            UserTaskOperationStatus.Completed => UserTaskPersistenceOperationStatus.Completed,
 0784            UserTaskOperationStatus.Failed => UserTaskPersistenceOperationStatus.Failed,
 2785            _ => UserTaskPersistenceOperationStatus.Enqueued
 3786        },
 3787        CreatedAt = value.CreatedAt, UpdatedAt = value.UpdatedAt, ActionKey = value.ActionKey, ProtectedPayloadJson = Se
 3788    };
 789
 48790    private static UserTaskInvitation ToInvitation(UserTaskInvitationRecord row) => new(
 48791        row.Id, row.TenantId, row.TaskId, Deserialize<string>(row.RecipientJson), row.TokenHash, row.Status,
 48792        row.IssuedAt, row.ExpiresAt, row.VerifierProvider, row.VerifiedAt, row.ConsumedAt, row.RevokedAt, row.SiblingGro
 48793    {
 48794        AllowedActions = Deserialize<List<string>>(row.AllowedActionsJson) ?? []
 48795    };
 796
 6797    private static UserTaskInvitationRecord ToInvitationRecord(UserTaskInvitation value) => new()
 6798    {
 6799        Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, RecipientJson = Serialize(value.Recipient), Tok
 6800        VerifierProvider = value.VerifierName ?? "default", Status = value.Status, IssuedAt = value.IssuedAt, ExpiresAt 
 6801        VerifiedAt = value.VerifiedAt, ConsumedAt = value.ConsumedAt, RevokedAt = value.RevokedAt, SiblingGroupId = valu
 6802        AllowedActionsJson = Serialize(value.AllowedActions)
 6803    };
 804
 122805    private static async Task<bool> ExistsByMaterializationKeyAsync(UserTasksElsaDbContext dbContext, string tenantId, s
 806
 807    private static void EnsureExpectedRevision(UserTaskRecord? existing, string taskId, int expectedRevision)
 808    {
 19809        if (existing is null)
 0810            throw new KeyNotFoundException($"User task '{taskId}' was not found.");
 19811        if (existing.Revision != expectedRevision)
 3812            throw new UserTaskRevisionConflictException(taskId, expectedRevision);
 16813    }
 814
 1070815    private static string Serialize<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
 297816    private static string Serialize(JsonElement? value) => !value.HasValue || value.Value.ValueKind == JsonValueKind.Und
 3416817    private static T? Deserialize<T>(string? value) => string.IsNullOrWhiteSpace(value) || string.Equals(value, "null", 
 792818    private static JsonElement? DeserializeJson(string? value) => Deserialize<JsonElement>(value);
 819}

Methods/Properties

.ctor(Elsa.Persistence.EFCore.Store`2<Elsa.UserTasks.Persistence.EFCore.UserTasksElsaDbContext,Elsa.UserTasks.Persistence.EFCore.UserTaskRecord>)
.cctor()
GetAsync()
QueryAsync()
FindByMaterializationKeyAsync()
FindByBookmarkIdAsync()
FindByInvitationTokenHashAsync()
SaveAsync()
AddProjectionAsync()
AppendEventAsync()
TryMutateAsync()
Elsa-UserTasks-Persistence-EFCore-Contracts-IUserTaskPersistenceAdapter-GetAsync()
QueryAsync()
TryAddProjectionAsync()
TrySaveAsync()
BuildQuery(Elsa.UserTasks.Persistence.EFCore.UserTasksElsaDbContext,Elsa.UserTasks.Models.UserTaskQuery)
ApplySafeSearch(System.Linq.IQueryable`1<Elsa.UserTasks.Persistence.EFCore.UserTaskRecord>,System.String)
ApplyOrdering(System.Linq.IQueryable`1<Elsa.UserTasks.Persistence.EFCore.UserTaskRecord>,Elsa.UserTasks.Models.UserTaskQuery)
ApplyCursor(System.Linq.IQueryable`1<Elsa.UserTasks.Persistence.EFCore.UserTaskRecord>,Elsa.UserTasks.Models.UserTaskQuery)
CreateCursor(Elsa.UserTasks.Persistence.EFCore.UserTaskRecord,System.String)
TryReadCursor(System.String,System.String&,System.String&)
LoadAggregateAsync()
LoadAggregatesAsync()
GroupByLoadedTask(System.Collections.Generic.IEnumerable`1<TRow>,System.Func`2<TRow,System.String>,System.Collections.Generic.Dictionary`2<System.String,Elsa.UserTasks.Models.UserTask>)
ToModel(Elsa.UserTasks.Persistence.EFCore.UserTaskRecord)
ToRecord(Elsa.UserTasks.Models.UserTask)
Copy(Elsa.UserTasks.Models.UserTask,Elsa.UserTasks.Persistence.EFCore.UserTaskRecord)
Copy(Elsa.UserTasks.Persistence.EFCore.UserTaskRecord,Elsa.UserTasks.Persistence.EFCore.UserTaskRecord)
ReplaceChildrenAsync()
ToCandidate(Elsa.UserTasks.Models.UserTask,Elsa.UserTasks.Models.ParticipantReference,Elsa.UserTasks.Persistence.EFCore.UserTaskPersistenceCandidateSource)
ToSnapshot(Elsa.UserTasks.Models.UserTask,Elsa.UserTasks.Models.ParticipantReference)
ToExclusion(Elsa.UserTasks.Models.UserTask,Elsa.UserTasks.Models.ParticipantReference)
ToParticipant(Elsa.UserTasks.Persistence.EFCore.UserTaskCandidateRecord)
ToParticipant(Elsa.UserTasks.Persistence.EFCore.UserTaskSnapshotMemberRecord)
ToParticipant(Elsa.UserTasks.Persistence.EFCore.UserTaskExclusionRecord)
ToParticipant(System.String,System.String,System.String,System.String,System.String)
GetParticipantKey(Elsa.UserTasks.Models.ParticipantReference)
ToEvent(Elsa.UserTasks.Persistence.EFCore.UserTaskEventRecord)
ToEventRecord(Elsa.UserTasks.Models.UserTaskEvent)
ToOperation(Elsa.UserTasks.Persistence.EFCore.UserTaskOperationRecord)
ToOperationRecord(Elsa.UserTasks.Models.UserTaskOperation)
ToInvitation(Elsa.UserTasks.Persistence.EFCore.UserTaskInvitationRecord)
ToInvitationRecord(Elsa.UserTasks.Models.UserTaskInvitation)
ExistsByMaterializationKeyAsync()
EnsureExpectedRevision(Elsa.UserTasks.Persistence.EFCore.UserTaskRecord,System.String,System.Int32)
Serialize(T)
Serialize(System.Nullable`1<System.Text.Json.JsonElement>)
Deserialize(System.String)
DeserializeJson(System.String)