AyCode.Core/docs/SIGNALR_DATASOURCE.md

209 lines
8.2 KiB
Markdown

# SignalR DataSource
Change-tracked real-time collection built on top of the SignalR transport layer. Source: `AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs`.
> For the underlying transport (tag system, wire protocol, dispatch) see [`SIGNALR.md`](SIGNALR.md).
## Overview
`AcSignalRDataSource` is a generic `IList<T>` that synchronizes with the server via CRUD tags. It handles change tracking, rollback, sync state, and binary deserialization — so consuming code works with a regular list while the DataSource manages communication.
```csharp
AcSignalRDataSource<TDataItem, TId, TIList>
where TDataItem : class, IId<TId> // entity with ID
where TId : struct // Guid, int, etc.
where TIList : class, IList<TDataItem> // List<T> or AcObservableCollection<T>
```
Implements `IList<TDataItem>`, `IList`, `IReadOnlyList<TDataItem>`.
**Constructor:**
```csharp
new AcSignalRDataSource<T, TId, TIList>(
AcSignalRClientBase signalRClient, // transport client
SignalRCrudTags crudTags, // 5 tags for CRUD operations
object[]? contextIds = null // optional server-side filter context
)
```
## SignalRCrudTags
A `sealed class` that bundles **5 independent tag integers** — one per CRUD operation. Tags are NOT sequential offsets; each tag is independently assigned:
```csharp
public sealed class SignalRCrudTags(
int getAllTag,
int getItemTag,
int addTag,
int updateTag,
int removeTag)
```
**Usage (consuming project):**
```csharp
// Tags are defined as independent constants in the project's tag class
public abstract class MyProjectTags : AcSignalRTags
{
public const int OrderGetAll = 300;
public const int OrderGetItem = 301;
public const int OrderAdd = 302;
public const int OrderUpdate = 303;
public const int OrderRemove = 304;
// Tags don't have to be sequential — they just happen to be here
}
// Construct with 5 explicit tags
var crudTags = new SignalRCrudTags(
MyProjectTags.OrderGetAll,
MyProjectTags.OrderGetItem,
MyProjectTags.OrderAdd,
MyProjectTags.OrderUpdate,
MyProjectTags.OrderRemove
);
```
**Tag lookup:** `GetMessageTagByTrackingState(TrackingState)` maps tracking state to the corresponding tag via switch expression.
## Data Loading
```csharp
// Awaits full response via sync-wait transport pattern (polls up to 60s)
await dataSource.LoadDataSource();
// Async callback — requests SignalResponseDataMessage directly to avoid double deserialization
await dataSource.LoadDataSourceAsync();
// Load from raw response bytes (public — usable when response is already available)
await dataSource.LoadDataSourceFromResponseData(responseData, serializerType);
// Single item by ID — updates or adds to InnerList
await dataSource.LoadItem(id);
```
All load methods are `async Task`. `LoadDataSource` uses the sync-wait transport pattern (`GetAllAsync<T>` → polls for response), while `LoadDataSourceAsync` uses the async callback path (avoids deserializing `ResponseData` into a typed object — works directly with `byte[]`).
**Binary deserialization paths:**
- `AcObservableCollection<T>`: `BeginUpdate()``BinaryToMerge()``EndUpdate()` — single batched UI notification.
- `List<T>`: `BinaryTo(InnerList)` — direct populate.
**Context/Filtering:** `ContextIds` (object[]) and `FilterText` (string) 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 |
| `CurrentValue` | Current item reference |
| `OriginalValue` | Clone for rollback (`JsonClone` or `ReflectionClone`) |
The `ChangeTracking<TDataItem, TId>` class manages the list of tracked items.
### CRUD Operations
```
Add(item): → TrackingState.Add + InnerList.Add(item)
AddOrUpdate(item): → exists? Update : Add (determines TrackingState automatically)
Insert(index, item): → TrackingState.Add + InnerList.Insert(index, item)
Update(i, item): → TrackingState.Update + clone original + InnerList[i] = item
Remove(id): → TrackingState.Remove + clone original + InnerList.RemoveAt(index)
```
Each operation has an optional `autoSave` parameter — if true, immediately calls `SaveItem()` for that single change.
**Manual tracking:** `SetTrackingStateToUpdate(item)` marks an existing item as modified without replacing it — useful when properties are mutated in-place.
### Events
| Event | Signature | Fires when |
|-------|-----------|------------|
| `OnDataSourceItemChanged` | `Func<ItemChangedEventArgs<T>, Task>?` | After each item is saved or loaded (carries item + TrackingState) |
| `OnDataSourceLoaded` | `Func<Task>?` | After `LoadDataSource` / `LoadDataSourceAsync` completes |
| `OnSyncingStateChanged` | `Action<bool>?` | On 0→1 (true) and 1→0 (false) sync transitions |
## SaveChanges
Two variants with different transport patterns:
| Method | Returns | Transport pattern | Use case |
|--------|---------|-------------------|----------|
| `SaveChanges()` | `List<TrackingItem>` (remaining failures) | Sync-wait (`ContinueWith`) | When caller needs to know what failed |
| `SaveChangesAsync()` | `Task` (void) | Fire-and-forget callback (`Action`) | Background save, no result inspection |
```
Both follow the same flow:
BeginSync()
for each tracked item:
tag = CrudTags.GetMessageTagByTrackingState(state)
response = SignalRClient.PostDataAsync(tag, item)
on success: remove from tracking, CopyTo InnerList item with server response
on failure: TryRollbackItem() → restore OriginalValue
EndSync()
```
**SaveItem(item, trackingState):** saves a single item (same flow). **SaveItem(id):** looks up tracking item by ID, then saves.
**Rollback:** `TryRollbackItem(id)` restores `OriginalValue` to `InnerList`. For `TrackingState.Add`: removes item entirely. For `Remove`: re-adds `OriginalValue`. Manual `Rollback()` reverts all tracked changes at once.
## Sync State
```csharp
private int _activeSyncOperations; // Interlocked counter
BeginSync(): Interlocked.Increment fires OnSyncingStateChanged(true) on 01
EndSync(): Interlocked.Decrement fires OnSyncingStateChanged(false) on 10
IsSyncing: _activeSyncOperations > 0
```
UI binds to `IsSyncing` to show loading indicators. The counter supports nested sync operations.
## Working Reference List
Allows an external list to become the DataSource's inner storage:
```csharp
dataSource.SetWorkingReferenceList(externalList);
// Now dataSource operates directly on externalList — same reference, no copy
// Read back the current inner list reference
TIList innerList = dataSource.GetReferenceInnerList();
```
This is useful when the UI already has a bound collection and you want the DataSource to manage it in-place. `HasWorkingReferenceList` indicates whether an external list has been set.
## Locking Strategy
| Lock | Scope | Used by |
|------|-------|---------|
| `object _syncRoot` | Synchronous | Count, Contains, IndexOf, GetEnumerator |
| `SemaphoreSlim _asyncLock` | Asynchronous | Add/Update/Remove with save, LoadDataSource |
`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. The flow:
```
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 [`SIGNALR.md` § How Projects Use Tags](SIGNALR.md#how-projects-use-tags).
## Key Source Files
| Component | Path |
|-----------|------|
| DataSource | `AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs` |
| CRUD tags | `AyCode.Services/SignalRs/SignalRCrudTags.cs` |
| Tracking helpers | `AyCode.Services.Server/SignalRs/TrackingItemHelpers.cs` |
| Transport client | `AyCode.Services/SignalRs/AcSignalRClientBase.cs` |
| Transport doc | [`docs/SIGNALR.md`](SIGNALR.md) |