< 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
73%
Covered lines: 420
Uncovered lines: 151
Coverable lines: 571
Total lines: 794
Line coverage: 73.5%
Branch coverage
72%
Covered branches: 197
Total branches: 272
Branch coverage: 72.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%272160%
TryAddProjectionAsync()0%4260%
TrySaveAsync()0%4260%
BuildQuery(...)56.52%1674661.44%
ApplyOrdering(...)100%1414100%
ApplyCursor(...)96.15%262693.75%
CreateCursor(...)100%1010100%
TryReadCursor(...)50%6676.92%
LoadAggregateAsync()100%11100%
LoadAggregatesAsync()85.71%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%210%
ToExclusion(...)100%11100%
ToParticipant(...)100%11100%
ToParticipant(...)100%210%
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    {
 11133        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 11134        var records = BuildQuery(dbContext, query);
 11135        int? totalCount = query.IncludeTotalCount ? await records.CountAsync(cancellationToken) : null;
 11136        var limit = Math.Clamp(query.Limit <= 0 ? 50 : query.Limit, 1, 200);
 11137        var rows = await ApplyCursor(ApplyOrdering(records, query), query).Take(limit + 1).ToListAsync(cancellationToken
 11138        var hasMore = rows.Count > limit;
 11139        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.
 11145        var items = await LoadAggregatesAsync(dbContext, page, cancellationToken);
 11146        return new UserTaskQueryResult(items, hasMore ? CreateCursor(page[^1], query.Sort) : null, totalCount);
 11147    }
 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    {
 95110        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 95111        if (await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken))
 112            return;
 113
 94114        dbContext.UserTasks.Add(ToRecord(task));
 188115        dbContext.UserTaskCandidates.AddRange(task.CandidateUsers.Select(x => ToCandidate(task, x, UserTaskPersistenceCa
 94116        dbContext.UserTaskCandidates.AddRange(task.CandidateGroups.Select(x => ToCandidate(task, x, UserTaskPersistenceC
 94117        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotMembers.Select(x => ToSnapshot(task, x)));
 94118        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotGroups.Select(x => ToSnapshot(task, x)));
 96119        dbContext.UserTaskExclusions.AddRange(task.ExcludedUsers.Select(x => ToExclusion(task, x)));
 94120        dbContext.UserTaskEvents.AddRange(task.Events.Select(ToEventRecord));
 94121        dbContext.UserTaskOperations.AddRange(task.Operations.Select(ToOperationRecord));
 94122        dbContext.UserTaskInvitations.AddRange(task.Invitations.Select(ToInvitationRecord));
 123        try
 124        {
 94125            await dbContext.SaveChangesAsync(cancellationToken);
 94126        }
 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        }
 95133    }
 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        if (!string.IsNullOrWhiteSpace(query.Search))
 195        {
 0196            var search = query.Search.Trim();
 0197            records = records.Where(x => x.Title.Contains(search) || (x.Summary != null && x.Summary.Contains(search)) |
 198        }
 199
 0200        var limit = query.Limit is > 0 ? Math.Min(query.Limit.Value, 200) : 100;
 0201        return await records.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenByDescending(x => x.Priority).ThenBy
 0202    }
 203
 204    public async Task<bool> TryAddProjectionAsync(UserTaskRecord task, CancellationToken cancellationToken = default)
 205    {
 0206        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 0207        if (await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationToken))
 0208            return false;
 0209        dbContext.UserTasks.Add(task);
 210        try
 211        {
 0212            await dbContext.SaveChangesAsync(cancellationToken);
 0213            return true;
 214        }
 0215        catch (DbUpdateException)
 216        {
 0217            if (!await ExistsByMaterializationKeyAsync(dbContext, task.TenantId, task.MaterializationKey, cancellationTo
 0218                throw;
 0219            return false;
 220        }
 0221    }
 222
 223    public async Task<bool> TrySaveAsync(UserTaskRecord task, int expectedRevision, CancellationToken cancellationToken 
 224    {
 0225        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 0226        var existing = await dbContext.UserTasks.FirstOrDefaultAsync(x => x.TenantId == task.TenantId && x.Id == task.Id
 0227        if (existing is null || existing.Revision != expectedRevision)
 0228            return false;
 0229        Copy(task, existing);
 0230        existing.Revision = expectedRevision + 1;
 0231        existing.UpdatedAt = DateTimeOffset.UtcNow;
 232        try
 233        {
 0234            await dbContext.SaveChangesAsync(cancellationToken);
 0235            return true;
 236        }
 0237        catch (DbUpdateConcurrencyException)
 238        {
 0239            return false;
 240        }
 0241    }
 242
 243    private static IQueryable<UserTaskRecord> BuildQuery(UserTasksElsaDbContext dbContext, UserTaskQuery query)
 244    {
 111245        var records = dbContext.UserTasks.AsNoTracking();
 111246        records = records.Where(x => x.TenantId == query.TenantId);
 111247        if (query.Statuses.Count > 0)
 248        {
 0249            var statuses = query.Statuses.ToArray();
 0250            records = records.Where(x => statuses.Contains(x.Status));
 251        }
 111252        if (query.OnlyOverdue)
 0253            records = records.Where(x => x.IsOverdue);
 111254        if (query.OnlyWithoutDueDate)
 0255            records = records.Where(x => x.DueAt == null);
 111256        if (!string.IsNullOrWhiteSpace(query.TaskType))
 0257            records = records.Where(x => x.TaskType == query.TaskType);
 111258        if (query.PriorityFrom is not null)
 0259            records = records.Where(x => x.Priority >= query.PriorityFrom);
 111260        if (query.PriorityTo is not null)
 0261            records = records.Where(x => x.Priority <= query.PriorityTo);
 111262        if (query.DueFrom is not null)
 0263            records = records.Where(x => x.DueAt >= query.DueFrom);
 111264        if (query.DueTo is not null)
 0265            records = records.Where(x => x.DueAt <= query.DueTo);
 111266        if (!string.IsNullOrWhiteSpace(query.WorkflowDefinitionId))
 0267            records = records.Where(x => x.WorkflowDefinitionId == query.WorkflowDefinitionId);
 111268        if (!string.IsNullOrWhiteSpace(query.WorkflowInstanceId))
 0269            records = records.Where(x => x.WorkflowInstanceId == query.WorkflowInstanceId);
 111270        if (!string.IsNullOrWhiteSpace(query.Reference))
 0271            records = records.Where(x => x.Reference == query.Reference);
 111272        if (!string.IsNullOrWhiteSpace(query.Search))
 273        {
 0274            var search = query.Search.Trim();
 0275            records = records.Where(x => x.Title.Contains(search) || (x.Summary != null && x.Summary.Contains(search)) |
 276        }
 111277        if (query.Scope is { } requestedScope &&
 111278            (!string.Equals(requestedScope.TenantId, query.TenantId, StringComparison.Ordinal) ||
 111279             !string.Equals(requestedScope.Subject.TenantId, query.TenantId, StringComparison.Ordinal) ||
 111280             requestedScope.Groups.Any(group => !string.Equals(group.TenantId, query.TenantId, StringComparison.Ordinal)
 2281            return records.Where(_ => false);
 282
 109283        if (query.Scope is not { } scope)
 0284            return records;
 285
 109286        var subject = scope.Subject;
 109287        var subjectType = subject.Type.ToString();
 109288        var groupKeys = scope.Groups.Select(GetParticipantKey).Distinct(StringComparer.Ordinal).ToArray();
 109289        var tenantId = query.TenantId;
 290
 109291        if (scope.ExcludeBlocking)
 109292            records = records.Where(task => task.HealthSeverity != UserTaskHealthSeverity.Blocking);
 293
 294        // Manager-only scopes were already rejected by the policy for non-managers. Reaching them here means
 295        // the caller manages the tenant, so the tenant predicate above is the whole authorization.
 109296        if (scope.RequiresManager)
 297        {
 0298            if (!scope.IsManager)
 0299                return records.Where(_ => false);
 0300            if (scope.Kind == UserTaskQueryScopeKind.NeedsAttention)
 301            {
 0302                records = records.Where(task =>
 0303                    task.HealthSeverity == UserTaskHealthSeverity.Blocking
 0304                    || task.IsOverdue
 0305                    || (task.AssigneeId == null && task.Status != UserTaskStatus.Completed && task.Status != UserTaskSta
 0306                    || task.Status == UserTaskStatus.Completing
 0307                    || task.Status == UserTaskStatus.TimingOut
 0308                    || task.Status == UserTaskStatus.Cancelling);
 309            }
 0310            return records;
 311        }
 312
 313        // Correlated predicates are applied in the query path so tenant, eligibility, and exclusion are
 314        // evaluated before totals, cursors, and page limits â€” an unauthorized row can never reach the page.
 109315        return scope.Kind switch
 109316        {
 1317            UserTaskQueryScopeKind.Assigned => records.Where(task =>
 1318                task.AssigneeProvider == subject.Provider && task.AssigneeType == subjectType && task.AssigneeId == subj
 109319
 108320            UserTaskQueryScopeKind.Available => records.Where(task =>
 108321                task.AssigneeId == null
 108322                && task.Status != UserTaskStatus.Completed && task.Status != UserTaskStatus.TimedOut && task.Status != U
 108323                && !dbContext.UserTaskExclusions.Any(exclusion =>
 108324                    exclusion.TenantId == tenantId && exclusion.TaskId == task.Id &&
 108325                    exclusion.ParticipantType == UserTaskParticipantType.User && exclusion.Provider == subject.Provider 
 108326                && (dbContext.UserTaskCandidates.Any(candidate =>
 108327                        candidate.TenantId == tenantId && candidate.TaskId == task.Id &&
 108328                        candidate.ParticipantType == UserTaskParticipantType.User && candidate.Provider == subject.Provi
 108329                    || (groupKeys.Length > 0 && dbContext.UserTaskCandidates.Any(candidate =>
 108330                        candidate.TenantId == tenantId && candidate.TaskId == task.Id &&
 108331                        candidate.ParticipantType == UserTaskParticipantType.Group && groupKeys.Contains(candidate.Parti
 108332                    || dbContext.UserTaskSnapshotMembers.Any(member =>
 108333                        member.TenantId == tenantId && member.TaskId == task.Id &&
 108334                        ((member.ParticipantType == UserTaskParticipantType.User && member.Provider == subject.Provider 
 108335                         || (member.ParticipantType == UserTaskParticipantType.Group && groupKeys.Contains(member.Partic
 109336
 0337            UserTaskQueryScopeKind.History => records.Where(task =>
 0338                (task.Status == UserTaskStatus.Completed || task.Status == UserTaskStatus.TimedOut || task.Status == Use
 0339                && dbContext.UserTaskEvents.Any(@event =>
 0340                    @event.TenantId == tenantId && @event.TaskId == task.Id &&
 0341                    @event.ActorProvider == subject.Provider && @event.ActorType == subjectType && @event.ActorId == sub
 109342
 0343            _ => records.Where(_ => false)
 109344        };
 345    }
 346
 347    private static IQueryable<UserTaskRecord> ApplyOrdering(IQueryable<UserTaskRecord> records, UserTaskQuery query)
 348    {
 111349        return query.Sort.ToLowerInvariant() switch
 111350        {
 39351            "due" when query.Descending => records.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x
 13352            "due" => records.OrderBy(x => x.DueAt == null).ThenBy(x => x.DueAt).ThenBy(x => x.Id),
 36353            "priority" when query.Descending => records.OrderByDescending(x => x.Priority).ThenBy(x => x.Id),
 12354            "priority" => records.OrderBy(x => x.Priority).ThenBy(x => x.Id),
 36355            "title" when query.Descending => records.OrderByDescending(x => x.Title).ThenBy(x => x.Id),
 12356            "title" => records.OrderBy(x => x.Title).ThenBy(x => x.Id),
 49357            _ when query.Descending => records.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id),
 25358            _ => records.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id)
 111359        };
 360    }
 361
 362    private static IQueryable<UserTaskRecord> ApplyCursor(IQueryable<UserTaskRecord> records, UserTaskQuery query)
 363    {
 111364        if (string.IsNullOrWhiteSpace(query.Cursor) || !TryReadCursor(query.Cursor, out var cursorValue, out var cursorI
 45365            return records;
 366
 367        return query.Sort.ToLowerInvariant() switch
 368        {
 32369            "priority" when int.TryParse(cursorValue, out var priority) => query.Descending
 16370                ? records.Where(x => x.Priority < priority || (x.Priority == priority && string.Compare(x.Id, cursorId) 
 16371                : records.Where(x => x.Priority > priority || (x.Priority == priority && string.Compare(x.Id, cursorId) 
 16372            "title" => query.Descending
 16373                ? records.Where(x => string.Compare(x.Title, cursorValue) < 0 || (x.Title == cursorValue && string.Compa
 16374                : records.Where(x => string.Compare(x.Title, cursorValue) > 0 || (x.Title == cursorValue && string.Compa
 18375            "due" when cursorValue == "~null" => records.Where(x => x.DueAt == null && string.Compare(x.Id, cursorId) > 
 28376            "due" when DateTimeOffset.TryParse(cursorValue, out var dueAt) => query.Descending
 14377                ? records.Where(x => x.DueAt == null || x.DueAt < dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cur
 14378                : records.Where(x => x.DueAt == null || x.DueAt > dueAt || (x.DueAt == dueAt && string.Compare(x.Id, cur
 36379            _ when DateTimeOffset.TryParse(cursorValue, out var createdAt) => query.Descending
 18380                ? records.Where(x => x.CreatedAt < createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursor
 18381                : records.Where(x => x.CreatedAt > createdAt || (x.CreatedAt == createdAt && string.Compare(x.Id, cursor
 0382            _ => records
 383        };
 384    }
 385
 386    private static string CreateCursor(UserTaskRecord record, string sort)
 387    {
 67388        var value = sort.ToLowerInvariant() switch
 67389        {
 16390            "priority" => record.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
 16391            "title" => record.Title,
 16392            "due" => record.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null",
 19393            _ => record.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
 67394        };
 67395        return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, record.Id }, JsonOptions))
 67396            .TrimEnd('=').Replace('+', '-').Replace('/', '_');
 397    }
 398
 399    private static bool TryReadCursor(string cursor, out string value, out string id)
 400    {
 67401        value = id = "";
 402        try
 403        {
 67404            var padded = cursor.Replace('-', '+').Replace('_', '/') + new string('=', (4 - cursor.Length % 4) % 4);
 67405            var parts = JsonSerializer.Deserialize<string[]>(Convert.FromBase64String(padded), JsonOptions);
 66406            if (parts is not [var cursorValue, var cursorId] || string.IsNullOrWhiteSpace(cursorId))
 0407                return false;
 66408            value = cursorValue;
 66409            id = cursorId;
 66410            return true;
 411        }
 0412        catch (FormatException)
 413        {
 0414            return false;
 415        }
 1416        catch (JsonException)
 417        {
 1418            return false;
 419        }
 67420    }
 421
 422    private static async Task<UserTask> LoadAggregateAsync(UserTasksElsaDbContext dbContext, UserTaskRecord record, Canc
 423    {
 99424        return (await LoadAggregatesAsync(dbContext, [record], cancellationToken))[0];
 99425    }
 426
 427    private static async Task<List<UserTask>> LoadAggregatesAsync(UserTasksElsaDbContext dbContext, IReadOnlyCollection<
 428    {
 210429        var tasks = records.Select(ToModel).ToList();
 210430        if (tasks.Count == 0)
 3431            return tasks;
 432
 207433        var tenantId = records.First().TenantId;
 533434        var taskIds = tasks.Select(x => x.Id).ToArray();
 533435        var taskById = tasks.ToDictionary(x => x.Id, StringComparer.Ordinal);
 207436        var candidates = await dbContext.UserTaskCandidates.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.
 1392437        foreach (var (task, group) in GroupByLoadedTask(candidates, x => x.TaskId, taskById))
 438        {
 652439            task.CandidateUsers = group.Where(x => x.ParticipantType == UserTaskParticipantType.User).Select(ToParticipa
 652440            task.CandidateGroups = group.Where(x => x.ParticipantType == UserTaskParticipantType.Group).Select(ToPartici
 441        }
 442
 207443        var snapshots = await dbContext.UserTaskSnapshotMembers.AsNoTracking().Where(x => x.TenantId == tenantId && task
 414444        foreach (var (task, group) in GroupByLoadedTask(snapshots, x => x.TaskId, taskById))
 445        {
 0446            task.SnapshotMembers = group.Where(x => x.ParticipantType == UserTaskParticipantType.User).Select(ToParticip
 0447            task.SnapshotGroups = group.Where(x => x.ParticipantType == UserTaskParticipantType.Group).Select(ToParticip
 448        }
 449
 207450        var exclusions = await dbContext.UserTaskExclusions.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.
 414451        foreach (var (task, group) in GroupByLoadedTask(exclusions, x => x.TaskId, taskById))
 0452            task.ExcludedUsers = group.Select(ToParticipant).ToList();
 453
 207454        var events = await dbContext.UserTaskEvents.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.Contains
 603455        foreach (var (task, group) in GroupByLoadedTask(events, x => x.TaskId, taskById))
 49456            task.Events = group.Select(ToEvent).ToList();
 457
 207458        var operations = await dbContext.UserTaskOperations.AsNoTracking().Where(x => x.TenantId == tenantId && taskIds.
 438459        foreach (var (task, group) in GroupByLoadedTask(operations, x => x.TaskId, taskById))
 7460            task.Operations = group.Select(ToOperation).ToList();
 461
 207462        var invitations = await dbContext.UserTaskInvitations.AsNoTracking().Where(x => x.TenantId == tenantId && taskId
 540463        foreach (var (task, group) in GroupByLoadedTask(invitations, x => x.TaskId, taskById))
 42464            task.Invitations = group.Select(ToInvitation).ToList();
 465
 207466        return tasks;
 210467    }
 468
 469    /// <summary>
 470    /// Pairs each group of child rows with the task it belongs to, dropping groups whose task is not on the
 471    /// current page. The filter is explicit and each group still costs a single dictionary probe.
 472    /// </summary>
 473    private static IEnumerable<(UserTask Task, IGrouping<string, TRow> Rows)> GroupByLoadedTask<TRow>(
 474        IEnumerable<TRow> rows, Func<TRow, string> taskIdSelector, Dictionary<string, UserTask> taskById) =>
 1242475        rows.GroupBy(taskIdSelector)
 424476            .Select(group => (Task: taskById.GetValueOrDefault(group.Key), Rows: group))
 424477            .Where(pair => pair.Task is not null)
 1666478            .Select(pair => (pair.Task!, pair.Rows));
 479
 326480    private static UserTask ToModel(UserTaskRecord record) => new()
 326481    {
 326482        Id = record.Id,
 326483        TenantId = record.TenantId,
 326484        WorkflowDefinitionId = record.WorkflowDefinitionId,
 326485        WorkflowDefinitionName = record.WorkflowDefinitionName,
 326486        WorkflowDefinitionVersion = record.WorkflowDefinitionVersion,
 326487        WorkflowInstanceId = record.WorkflowInstanceId,
 326488        WorkflowInstanceReference = record.WorkflowInstanceReference,
 326489        ActivityInstanceId = record.ActivityInstanceId,
 326490        BookmarkId = record.BookmarkId,
 326491        MaterializationKey = record.MaterializationKey,
 326492        Title = record.Title,
 326493        Summary = record.Summary,
 326494        Reference = record.Reference,
 326495        Tags = Deserialize<HashSet<string>>(record.TagsJson) ?? new(StringComparer.OrdinalIgnoreCase),
 326496        TaskType = record.TaskType,
 326497        Requester = ToParticipant(record.RequesterProvider, record.RequesterType, record.RequesterId, record.RequesterDi
 326498        Assignee = ToParticipant(record.AssigneeProvider, record.AssigneeType, record.AssigneeId, record.AssigneeDisplay
 326499        MembershipResolutionMode = record.MembershipResolutionMode ?? UserTaskMembershipResolutionMode.Live,
 326500        AllowManagerExclusionOverride = record.AllowManagerExclusionOverride,
 326501        Priority = record.Priority,
 326502        DueAt = record.DueAt,
 326503        IsOverdue = record.IsOverdue,
 326504        Instructions = Deserialize<string>(record.InstructionsJson),
 326505        TaskData = DeserializeJson(record.TaskDataJson),
 326506        RequestedForm = Deserialize<UserTaskFormReference>(record.FormReferenceJson),
 326507        PinnedForm = Deserialize<ResolvedUserTaskForm>(record.PinnedFormJson),
 326508        Actions = Deserialize<List<UserTaskAction>>(record.ActionsJson) ?? [new UserTaskAction("Complete", "Complete")],
 326509        InvitationDefinitions = Deserialize<List<UserTaskInvitationDefinition>>(record.InvitationDefinitionsJson) ?? [],
 326510        EnableTimeoutOutcome = record.TimeoutEnabled,
 326511        EnableCancellationOutcome = record.CancellationEnabled,
 326512        Status = record.Status,
 326513        HealthSeverity = record.HealthSeverity,
 326514        HealthCode = record.HealthCode,
 326515        HealthMessage = record.HealthMessage,
 326516        CompletionActionKey = record.CompletionActionKey,
 326517        CompletionData = DeserializeJson(record.CompletionDataJson),
 326518        CompletedBy = Deserialize<ParticipantReference>(record.CompletionActorJson),
 326519        CreatedAt = record.CreatedAt,
 326520        UpdatedAt = record.UpdatedAt,
 326521        AssignedAt = record.AssignedAt,
 326522        CompletedAt = record.CompletedAt,
 326523        Revision = record.Revision
 326524    };
 525
 526    private static UserTaskRecord ToRecord(UserTask task)
 527    {
 94528        var record = new UserTaskRecord { Id = string.IsNullOrWhiteSpace(task.Id) ? Guid.NewGuid().ToString("N") : task.
 94529        Copy(task, record);
 94530        return record;
 531    }
 532
 533    private static void Copy(UserTask source, UserTaskRecord target)
 534    {
 119535        target.Id = source.Id;
 119536        target.TenantId = source.TenantId;
 119537        target.WorkflowDefinitionId = source.WorkflowDefinitionId;
 119538        target.WorkflowDefinitionName = source.WorkflowDefinitionName;
 119539        target.WorkflowDefinitionVersion = source.WorkflowDefinitionVersion;
 119540        target.WorkflowInstanceId = source.WorkflowInstanceId;
 119541        target.WorkflowInstanceReference = source.WorkflowInstanceReference;
 119542        target.ActivityInstanceId = source.ActivityInstanceId;
 119543        target.BookmarkId = source.BookmarkId;
 119544        target.MaterializationKey = source.MaterializationKey;
 119545        target.Title = source.Title;
 119546        target.Summary = source.Summary;
 119547        target.Reference = source.Reference;
 119548        target.TaskType = source.TaskType;
 119549        target.TagsJson = Serialize(source.Tags);
 119550        target.RequesterProvider = source.Requester?.Provider;
 119551        target.RequesterType = source.Requester?.Type.ToString();
 119552        target.RequesterId = source.Requester?.Id;
 119553        target.RequesterDisplayName = source.Requester?.DisplayName;
 119554        target.Priority = source.Priority;
 119555        target.DueAt = source.DueAt;
 119556        target.IsOverdue = source.IsOverdue;
 119557        target.Status = source.Status;
 119558        target.TimeoutEnabled = source.EnableTimeoutOutcome;
 119559        target.CancellationEnabled = source.EnableCancellationOutcome;
 119560        target.AllowManagerExclusionOverride = source.AllowManagerExclusionOverride;
 119561        target.MembershipResolutionMode = source.MembershipResolutionMode;
 119562        target.AssigneeProvider = source.Assignee?.Provider;
 119563        target.AssigneeType = source.Assignee?.Type.ToString();
 119564        target.AssigneeId = source.Assignee?.Id;
 119565        target.AssigneeDisplayName = source.Assignee?.DisplayName;
 119566        target.InstructionsJson = Serialize(source.Instructions);
 119567        target.TaskDataJson = Serialize(source.TaskData);
 119568        target.FormReferenceJson = Serialize(source.RequestedForm);
 119569        target.PinnedFormJson = Serialize(source.PinnedForm);
 119570        target.ActionsJson = Serialize(source.Actions);
 119571        target.InvitationDefinitionsJson = Serialize(source.InvitationDefinitions);
 119572        target.HealthIssuesJson = Serialize(new { source.HealthSeverity, source.HealthCode, source.HealthMessage });
 119573        target.HealthSeverity = source.HealthSeverity;
 119574        target.HealthCode = source.HealthCode;
 119575        target.HealthMessage = source.HealthMessage;
 119576        target.CompletionActionKey = source.CompletionActionKey;
 119577        target.CompletionDataJson = Serialize(source.CompletionData);
 119578        target.CompletionActorJson = Serialize(source.CompletedBy);
 119579        target.CreatedAt = source.CreatedAt;
 119580        target.UpdatedAt = source.UpdatedAt;
 119581        target.AssignedAt = source.AssignedAt;
 119582        target.CompletedAt = source.CompletedAt;
 119583        target.Revision = source.Revision;
 119584    }
 585
 586    private static void Copy(UserTaskRecord source, UserTaskRecord target)
 587    {
 0588        target.TenantId = source.TenantId;
 0589        target.WorkflowDefinitionId = source.WorkflowDefinitionId;
 0590        target.WorkflowDefinitionName = source.WorkflowDefinitionName;
 0591        target.WorkflowDefinitionVersion = source.WorkflowDefinitionVersion;
 0592        target.WorkflowInstanceId = source.WorkflowInstanceId;
 0593        target.WorkflowInstanceReference = source.WorkflowInstanceReference;
 0594        target.ActivityInstanceId = source.ActivityInstanceId;
 0595        target.BookmarkId = source.BookmarkId;
 0596        target.MaterializationKey = source.MaterializationKey;
 0597        target.Title = source.Title;
 0598        target.Summary = source.Summary;
 0599        target.Reference = source.Reference;
 0600        target.TaskType = source.TaskType;
 0601        target.TagsJson = source.TagsJson;
 0602        target.RequesterProvider = source.RequesterProvider;
 0603        target.RequesterType = source.RequesterType;
 0604        target.RequesterId = source.RequesterId;
 0605        target.RequesterDisplayName = source.RequesterDisplayName;
 0606        target.Priority = source.Priority;
 0607        target.DueAt = source.DueAt;
 0608        target.IsOverdue = source.IsOverdue;
 0609        target.Status = source.Status;
 0610        target.TimeoutEnabled = source.TimeoutEnabled;
 0611        target.CancellationEnabled = source.CancellationEnabled;
 0612        target.AllowManagerExclusionOverride = source.AllowManagerExclusionOverride;
 0613        target.MembershipResolutionMode = source.MembershipResolutionMode;
 0614        target.AssigneeProvider = source.AssigneeProvider;
 0615        target.AssigneeType = source.AssigneeType;
 0616        target.AssigneeId = source.AssigneeId;
 0617        target.AssigneeDisplayName = source.AssigneeDisplayName;
 0618        target.InstructionsJson = source.InstructionsJson;
 0619        target.TaskDataJson = source.TaskDataJson;
 0620        target.FormReferenceJson = source.FormReferenceJson;
 0621        target.PinnedFormJson = source.PinnedFormJson;
 0622        target.ActionsJson = source.ActionsJson;
 0623        target.InvitationDefinitionsJson = source.InvitationDefinitionsJson;
 0624        target.HealthIssuesJson = source.HealthIssuesJson;
 0625        target.HealthSeverity = source.HealthSeverity;
 0626        target.HealthCode = source.HealthCode;
 0627        target.HealthMessage = source.HealthMessage;
 0628        target.CompletionActionKey = source.CompletionActionKey;
 0629        target.CompletionDataJson = source.CompletionDataJson;
 0630        target.CompletionActorJson = source.CompletionActorJson;
 0631        target.CreatedAt = source.CreatedAt;
 0632        target.UpdatedAt = source.UpdatedAt;
 0633        target.AssignedAt = source.AssignedAt;
 0634        target.CompletedAt = source.CompletedAt;
 0635        target.CreatedFromBookmarkRevision = source.CreatedFromBookmarkRevision;
 0636    }
 637
 638    private static async Task ReplaceChildrenAsync(UserTasksElsaDbContext dbContext, UserTask task, CancellationToken ca
 639    {
 25640        dbContext.UserTaskCandidates.RemoveRange(await dbContext.UserTaskCandidates.Where(x => x.TenantId == task.Tenant
 25641        dbContext.UserTaskSnapshotMembers.RemoveRange(await dbContext.UserTaskSnapshotMembers.Where(x => x.TenantId == t
 25642        dbContext.UserTaskExclusions.RemoveRange(await dbContext.UserTaskExclusions.Where(x => x.TenantId == task.Tenant
 25643        var existingEvents = await dbContext.UserTaskEvents.Where(x => x.TenantId == task.TenantId && x.TaskId == task.I
 41644        var existingEventIds = existingEvents.Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
 41645        var existingEventRevisions = existingEvents.Select(x => x.Revision).ToHashSet();
 56646        dbContext.UserTaskEvents.AddRange(task.Events.Where(x => !existingEventIds.Contains(x.Id) && !existingEventRevis
 647
 25648        var existingOperations = await dbContext.UserTaskOperations.Where(x => x.TenantId == task.TenantId && x.TaskId =
 27649        var existingOperationIds = existingOperations.Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
 58650        foreach (var operation in task.Operations)
 651        {
 7652            var entity = existingOperations.FirstOrDefault(x => x.Id == operation.Id || x.OperationId == operation.Opera
 4653            if (entity is null)
 654            {
 2655                if (!existingOperationIds.Contains(operation.Id))
 2656                    dbContext.UserTaskOperations.Add(ToOperationRecord(operation));
 2657                continue;
 658            }
 659
 2660            entity.Kind = operation.Kind.ToString();
 2661            entity.ExpectedRevision = operation.ExpectedRevision;
 2662            entity.RequestHash = operation.RequestHash;
 2663            entity.Status = operation.Status switch
 2664            {
 2665                UserTaskOperationStatus.Completed => UserTaskPersistenceOperationStatus.Completed,
 0666                UserTaskOperationStatus.Failed => UserTaskPersistenceOperationStatus.Failed,
 0667                _ => UserTaskPersistenceOperationStatus.Enqueued
 2668            };
 2669            entity.UpdatedAt = operation.UpdatedAt;
 2670            entity.ActionKey = operation.ActionKey;
 2671            entity.ProtectedPayloadJson = Serialize(operation.Data);
 2672            entity.ErrorCode = operation.ErrorCode;
 673        }
 674
 25675        var existingInvitations = await dbContext.UserTaskInvitations.Where(x => x.TenantId == task.TenantId && x.TaskId
 33676        var existingInvitationIds = existingInvitations.Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
 74677        foreach (var invitation in task.Invitations)
 678        {
 20679            var entity = existingInvitations.FirstOrDefault(x => x.Id == invitation.Id);
 12680            if (entity is null)
 681            {
 4682                if (!existingInvitationIds.Contains(invitation.Id))
 4683                    dbContext.UserTaskInvitations.Add(ToInvitationRecord(invitation));
 4684                continue;
 685            }
 686
 8687            entity.Status = invitation.Status;
 8688            entity.VerifiedAt = invitation.VerifiedAt;
 8689            entity.ConsumedAt = invitation.ConsumedAt;
 8690            entity.RevokedAt = invitation.RevokedAt;
 691        }
 50692        dbContext.UserTaskCandidates.AddRange(task.CandidateUsers.Select(x => ToCandidate(task, x, UserTaskPersistenceCa
 25693        dbContext.UserTaskCandidates.AddRange(task.CandidateGroups.Select(x => ToCandidate(task, x, UserTaskPersistenceC
 25694        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotMembers.Select(x => ToSnapshot(task, x)));
 25695        dbContext.UserTaskSnapshotMembers.AddRange(task.SnapshotGroups.Select(x => ToSnapshot(task, x)));
 25696        dbContext.UserTaskExclusions.AddRange(task.ExcludedUsers.Select(x => ToExclusion(task, x)));
 25697    }
 698
 119699    private static UserTaskCandidateRecord ToCandidate(UserTask task, ParticipantReference participant, UserTaskPersiste
 119700    {
 119701        TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(
 119702        ParticipantId = participant.Id, DisplayName = participant.DisplayName, Source = source
 119703    };
 704
 0705    private static UserTaskSnapshotMemberRecord ToSnapshot(UserTask task, ParticipantReference participant) => new()
 0706    {
 0707        TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(
 0708        ParticipantId = participant.Id, CreatedAt = task.CreatedAt
 0709    };
 710
 2711    private static UserTaskExclusionRecord ToExclusion(UserTask task, ParticipantReference participant) => new()
 2712    {
 2713        TenantId = task.TenantId, TaskId = task.Id, Provider = participant.Provider, ParticipantKey = GetParticipantKey(
 2714        ParticipantId = participant.Id, CreatedAt = task.CreatedAt
 2715    };
 716
 326717    private static ParticipantReference ToParticipant(UserTaskCandidateRecord row) => new(row.TenantId, row.Provider, ro
 0718    private static ParticipantReference ToParticipant(UserTaskSnapshotMemberRecord row) => new(row.TenantId, row.Provide
 0719    private static ParticipantReference ToParticipant(UserTaskExclusionRecord row) => new(row.TenantId, row.Provider, ro
 720
 721    private static ParticipantReference? ToParticipant(string? provider, string? type, string? id, string? displayName, 
 722    {
 656723        if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(id) || !
 623724            return null;
 33725        return new ParticipantReference(tenantId, provider, participantType, id, displayName);
 726    }
 727
 121728    private static string GetParticipantKey(ParticipantReference participant) => $"{participant.Provider}|{participant.T
 729
 91730    private static UserTaskEvent ToEvent(UserTaskEventRecord row) => new(row.Id, row.TenantId, row.TaskId, row.Revision,
 731
 18732    private static UserTaskEventRecord ToEventRecord(UserTaskEvent value) => new()
 18733    {
 18734        Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, Revision = value.Revision, EventType = value.Ev
 18735        OccurredAt = value.OccurredAt, ActorProvider = value.Actor?.Provider, ActorType = value.Actor?.Type.ToString(), 
 18736        ActorJson = Serialize(value.Actor), OperationId = value.OperationId, Reason = value.Reason,
 18737        MetadataJson = Serialize(value.Metadata ?? new Dictionary<string, object?>())
 18738    };
 739
 10740    private static UserTaskOperation ToOperation(UserTaskOperationRecord row) => new(
 10741        row.Id, row.TenantId, row.TaskId, row.OperationId,
 10742        Enum.TryParse<UserTaskOperationKind>(row.Kind, true, out var kind) ? kind : UserTaskOperationKind.Claim,
 10743        row.ExpectedRevision, row.RequestHash,
 10744        row.Status switch
 10745        {
 6746            UserTaskPersistenceOperationStatus.Completed => UserTaskOperationStatus.Completed,
 0747            UserTaskPersistenceOperationStatus.Failed => UserTaskOperationStatus.Failed,
 4748            _ => UserTaskOperationStatus.Accepted
 10749        },
 10750        row.CreatedAt, row.UpdatedAt, row.ActionKey, DeserializeJson(row.ProtectedPayloadJson), row.ErrorCode);
 751
 3752    private static UserTaskOperationRecord ToOperationRecord(UserTaskOperation value) => new()
 3753    {
 3754        Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, OperationId = value.OperationId, Kind = value.K
 3755        ExpectedRevision = value.ExpectedRevision, RequestHash = value.RequestHash,
 3756        Status = value.Status switch
 3757        {
 1758            UserTaskOperationStatus.Completed => UserTaskPersistenceOperationStatus.Completed,
 0759            UserTaskOperationStatus.Failed => UserTaskPersistenceOperationStatus.Failed,
 2760            _ => UserTaskPersistenceOperationStatus.Enqueued
 3761        },
 3762        CreatedAt = value.CreatedAt, UpdatedAt = value.UpdatedAt, ActionKey = value.ActionKey, ProtectedPayloadJson = Se
 3763    };
 764
 48765    private static UserTaskInvitation ToInvitation(UserTaskInvitationRecord row) => new(
 48766        row.Id, row.TenantId, row.TaskId, Deserialize<string>(row.RecipientJson), row.TokenHash, row.Status,
 48767        row.IssuedAt, row.ExpiresAt, row.VerifierProvider, row.VerifiedAt, row.ConsumedAt, row.RevokedAt, row.SiblingGro
 48768    {
 48769        AllowedActions = Deserialize<List<string>>(row.AllowedActionsJson) ?? []
 48770    };
 771
 6772    private static UserTaskInvitationRecord ToInvitationRecord(UserTaskInvitation value) => new()
 6773    {
 6774        Id = value.Id, TenantId = value.TenantId, TaskId = value.TaskId, RecipientJson = Serialize(value.Recipient), Tok
 6775        VerifierProvider = value.VerifierName ?? "default", Status = value.Status, IssuedAt = value.IssuedAt, ExpiresAt 
 6776        VerifiedAt = value.VerifiedAt, ConsumedAt = value.ConsumedAt, RevokedAt = value.RevokedAt, SiblingGroupId = valu
 6777        AllowedActionsJson = Serialize(value.AllowedActions)
 6778    };
 779
 95780    private static async Task<bool> ExistsByMaterializationKeyAsync(UserTasksElsaDbContext dbContext, string tenantId, s
 781
 782    private static void EnsureExpectedRevision(UserTaskRecord? existing, string taskId, int expectedRevision)
 783    {
 19784        if (existing is null)
 0785            throw new KeyNotFoundException($"User task '{taskId}' was not found.");
 19786        if (existing.Revision != expectedRevision)
 3787            throw new UserTaskRevisionConflictException(taskId, expectedRevision);
 16788    }
 789
 1000790    private static string Serialize<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
 243791    private static string Serialize(JsonElement? value) => !value.HasValue || value.Value.ValueKind == JsonValueKind.Und
 3222792    private static T? Deserialize<T>(string? value) => string.IsNullOrWhiteSpace(value) || string.Equals(value, "null", 
 662793    private static JsonElement? DeserializeJson(string? value) => Deserialize<JsonElement>(value);
 794}

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)
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)