# SignalR DataSource

Change-tracked real-time collection on top of the SignalR transport layer. Source: `SignalRs/AcSignalRDataSource.cs` in `AyCode.Services`.

> For the underlying transport (tag system, wire protocol, client base) see `AyCode.Services/docs/SIGNALR/README.md`.
> For server hub infrastructure see `AyCode.Services.Server/docs/SIGNALR/README.md`.

## Overview

`AcSignalRDataSource` — **abstract** generic `IList<T>` synchronized via CRUD tags. Handles change tracking, rollback, sync state, pluggable Binary/JSON+GZip deserialization — consuming code works with a regular list while the DataSource manages communication.

```csharp
public abstract class AcSignalRDataSource<TDataItem, TId, TIList> : IList<TDataItem>, IList, IReadOnlyList<TDataItem>
    where TDataItem : class, IId<TId>     // entity with ID
    where TId : struct                     // Guid, int, etc.
    where TIList : class, IList<TDataItem> // List<T> or AcObservableCollection<T>
```

Consumers must subclass — the class is abstract:

```csharp
public class MySampleDataSource : AcSignalRDataSource<MyEntity, Guid, AcObservableCollection<MyEntity>>
{
    public MySampleDataSource(AcSignalRClientBase client, SignalRCrudTags tags)
        : base(client, tags) { }
}
```

**Constructor:**

```csharp
new MySampleDataSource(
    AcSignalRClientBase signalRClient,   // transport client
    SignalRCrudTags crudTags,            // 5 tags for CRUD operations
    object[]? contextIds = null          // optional server-side filter context
)
```

## SignalRCrudTags

A `sealed class` bundling **5 independent tag integers** — one per CRUD operation. Tags are NOT sequential offsets; each is independently assigned:

```csharp
public sealed class SignalRCrudTags(
    int getAllTag,
    int getItemTag,
    int addTag,
    int updateTag,
    int removeTag)
```

**Usage (consuming project — domain-agnostic example):**

```csharp
public abstract class MyAppSignalRTags : AcSignalRTags
{
    public const int SampleGetAll  = 300;
    public const int SampleGetItem = 301;
    public const int SampleAdd     = 302;
    public const int SampleUpdate  = 303;
    public const int SampleRemove  = 304;
}

var crudTags = new SignalRCrudTags(
    MyAppSignalRTags.SampleGetAll,
    MyAppSignalRTags.SampleGetItem,
    MyAppSignalRTags.SampleAdd,
    MyAppSignalRTags.SampleUpdate,
    MyAppSignalRTags.SampleRemove
);
```

**Tag lookup:** `GetMessageTagByTrackingState(TrackingState)` — switch expression mapping state → tag.

## Serializer Selection

```csharp
public AcSerializerType SerializerType { get; set; } = AcSerializerType.Binary;
```

Controls deserialization format on the **raw byte[]** load path (see Data Loading). Must match server's `SerializerOptions.SerializerType` for that endpoint. Default `Binary`; override to `JsonGZip` for JSON datasources.

## Data Loading

```csharp
await dataSource.LoadDataSource(clearChangeTracking: true);             // sync-wait transport
await dataSource.LoadDataSourceAsync(clearChangeTracking: true);        // async callback path
await dataSource.LoadDataSource(fromSource, refreshDataFromDbAsync: false,
    setSourceToWorkingReferenceList: false, clearChangeTracking: true); // in-memory load (no transport)
await dataSource.LoadDataSourceFromResponseData(responseData, serializerType,
    refreshDataFromDbAsync: false, setSourceToWorkingReferenceList: false,
    clearChangeTracking: true);                                          // pre-fetched payload
await dataSource.LoadItem(id);                                            // single item by ID
                                                                          // fires OnDataSourceItemChanged
                                                                          // with TrackingState.Get
```

**Three deserialization paths** in `LoadDataSourceFromResponseData`:

1. **Raw byte[]** (`responseData is byte[]`) — consumer-side deserialize:
   - `AcSerializerType.Binary` + `IAcObservableCollection`: `BeginUpdate()` → `AcBinaryDeserializer.PopulateMerge` → `EndUpdate()` (single batched UI notification).
   - `AcSerializerType.Binary` + plain `IList<T>`: `AcBinaryDeserializer.Populate`.
   - `AcSerializerType.JsonGZip` + `IAcObservableCollection`: `GzipHelper.DecompressToString` → `PopulateFromJson`.
   - `AcSerializerType.JsonGZip` + plain `IList<T>`: `GzipHelper.DecompressToString` → `JsonTo`.

2. **Typed object** (`responseData is TIList`) — protocol already deserialized:
   - `IAcObservableCollection`: `BeginUpdate` → `Clear` + `foreach Add` → `EndUpdate`.
   - Otherwise: direct `Clear` + `foreach Add`.

3. **Fallback re-serialize** (responseData neither `byte[]` nor `TIList` — e.g., bare `List<T>` in test scenarios without protocol): `AcBinarySerializer.Serialize(responseData)` → bytes → process as Path 1 (Binary).

**Context/Filtering:** `ContextIds` (`object[]`) and `CustomFilterCriteria` (`string` — a DevExpress criteria string; renamed from `FilterText` → `CustomFilterText` 2026-07-16) are sent with every GetAll request for server-side filtering.

## Change Tracking

Each modified item is wrapped in `TrackingItem<TDataItem, TId>`:

| Field | Purpose |
|---|---|
| `TrackingState` | `Add`, `Update`, `Remove`, `Get` |
| `CurrentValue` | Current item reference |
| `OriginalValue` | Clone for rollback (`TrackingItemHelpers.JsonClone` or `ReflectionClone`) |

The `ChangeTracking<TDataItem, TId>` class manages the list of tracked items.

### CRUD Operations

```
Add(item):                       → TrackingState.Add + InnerList.Add(item)
Add(item, autoSave):             → optionally calls SaveItem after
AddOrUpdate(item, autoSave):     → exists? Update : Add (TrackingState auto-determined)
Insert(index, item):             → TrackingState.Add + InnerList.Insert(index, item)
Insert(index, item, autoSave):   → optionally calls SaveItem after
Update(i, item, autoSave):       → TrackingState.Update + clone original + InnerList[i] = item
Update(item, autoSave):          → as Update(i, ...) by item.Id lookup
Remove(id, autoSave):            → TrackingState.Remove + clone original + InnerList.RemoveAt
Remove(item, autoSave):          → as Remove(id) by item.Id
TryRemove(id, out item):         → no-throw remove (returns bool)
RemoveAt(index, autoSave):       → by index
```

`autoSave` parameter — if true, immediately calls `SaveItem()` for that single change after the local mutation.

**Manual tracking:** `SetTrackingStateToUpdate(item)` marks existing item as modified without replacing — for in-place property mutations.

### Additional collection helpers

| Member | Returns | Purpose |
|---|---|---|
| `AddRange(IEnumerable)` | `void` | batch insert (observable-aware: `IAcObservableCollection.AddRange` or `List<T>.AddRange`, fallback per-item `Add`) |
| `TryGetValue(id, out item)` | `bool` | ID-keyed lookup |
| `TryGetIndex(id, out index)` | `bool` | index-keyed lookup |
| `IndexOf(id)` / `IndexOf(item)` | `int` | -1 if not found |
| `Contains(item)` | `bool` | by ID |
| `AsReadOnly()` | `ReadOnlyCollection<TDataItem>` | wrap |
| `BinarySearch(...)` | `int` | currently throws `NotImplementedException` (see SIGNALR_DATASOURCE_ISSUES.md) |

### Events

| Event | Signature | Fires when |
|---|---|---|
| `OnDataSourceItemChanged` | `Func<ItemChangedEventArgs<T>, Task>?` | After each item is saved or `LoadItem` completes (TrackingState in args) |
| `OnDataSourceLoaded` | `Func<Task>?` | After `LoadDataSource*` completes |
| `OnSyncingStateChanged` | `Action<bool>?` | On 0→1 (true) and 1→0 (false) sync transitions |

`ItemChangedEventArgs<T>` carries `Item: T` and `TrackingState: TrackingState`.

## SaveChanges

| Method | Returns | Transport pattern | Use case |
|---|---|---|---|
| `SaveChanges()` | `Task<List<TrackingItem>>` (remaining failures) | Sync-wait via `ContinueWith` | Caller needs to know what failed |
| `SaveChangesAsync()` | `Task` (void) | Fire-and-forget callback (`Action<SignalResponseDataMessage>`) | Background save, no result inspection |
| `SaveItem(id)` | `Task<TDataItem>` | Sync-wait | Save a single tracked item |
| `SaveItem(id, trackingState)` | `Task<TDataItem>` | Sync-wait | Save with explicit state |
| `SaveItem(item, trackingState)` | `Task<TDataItem>` | Sync-wait | Save without lookup |

```
Both batch flows:
  BeginSync()
  for each tracked item:
    tag = CrudTags.GetMessageTagByTrackingState(state)
    response = SignalRClient.PostDataAsync(tag, item)
    on success: ProcessSavedResponseItem → CopyTo InnerList item, remove tracking,
                fire OnDataSourceItemChanged
    on failure: TryRollbackItem() → restore OriginalValue
  EndSync()
```

## Rollback

```csharp
bool TryRollbackItem(TId id, out TDataItem? originalValue);
void Rollback();  // reverts all tracked changes
```

`TryRollbackItem` restores `OriginalValue` to `InnerList`:
- `TrackingState.Add`: removes item entirely (no original existed).
- `TrackingState.Update` / `Remove`: re-applies `OriginalValue.CopyTo(InnerList[index])`, or re-adds if missing.
- The tracking entry is removed in either case.

`Rollback()` iterates and calls `TryRollbackItem` for every tracked item.

## Sync State

```csharp
private int _activeSyncOperations;  // Interlocked counter

BeginSync(): Interlocked.Increment → fires OnSyncingStateChanged(true) on 0→1
EndSync():   Interlocked.Decrement → fires OnSyncingStateChanged(false) on 1→0
IsSyncing:   _activeSyncOperations > 0
```

UI binds to `IsSyncing` to show loading indicators. The counter supports nested sync operations.

## Working Reference List

```csharp
dataSource.SetWorkingReferenceList(externalList);
// Now dataSource operates directly on externalList — same reference, no copy
TIList innerList = dataSource.GetReferenceInnerList();
bool hasRef    = dataSource.HasWorkingReferenceList;
```

For UIs with an already-bound collection that the DataSource manages in-place.

## Locking Strategy

| Lock | Scope | Used by |
|---|---|---|
| `object _syncRoot` | Synchronous | Count, Contains, IndexOf, GetEnumerator, Add (no-save), Insert (no-save), CopyTo |
| `SemaphoreSlim _asyncLock` | Asynchronous | LoadDataSource*, AddOrUpdate, Update, Remove (with save), SaveItem, SaveChanges* |

`GetEnumerator()` returns `InnerList.ToList().GetEnumerator()` — safe copy to avoid mutation during iteration.

## Relationship to Transport

The DataSource is a **consumer** of the SignalR transport, not part of it:

```
DataSource.SaveChanges()
  → CrudTags.GetMessageTagByTrackingState(state) → tag
  → AcSignalRClientBase.PostDataAsync(tag, item)     ← transport layer
  → OnReceiveMessage(tag, bytes, requestId)           ← wire protocol
  → Server method with [SignalR(tag)]                 ← tag dispatch
```

Projects can also call the transport directly without DataSource — see `AyCode.Services/docs/SIGNALR/README.md`.

> **Cross-cutting:** `ACCORE-SIG-I-T7S2` (GetAll raw byte[] for populate/merge) lives in `../SIGNALR/SIGNALR_ISSUES.md` because it spans transport + DataSource concerns equally.

## Key Source Files

| Component | Path |
|---|---|
| DataSource (abstract base) | `SignalRs/AcSignalRDataSource.cs` (in `AyCode.Services`) |
| Tracking helpers (`JsonClone` / `ReflectionClone`) | `SignalRs/TrackingItemHelpers.cs` (in `AyCode.Services`) |
| CRUD tags | `SignalRs/SignalRCrudTags.cs` (in `AyCode.Services`) |
| Transport client | `SignalRs/AcSignalRClientBase.cs` (in `AyCode.Services`) |
