AyCode.Core/AyCode.Services.Server/docs/SIGNALR_DATASOURCE
Loretta 8e9a0b47c1 [LOADED_DOCS: 3 files, no new loads]
Expand META-TODO scope; add BINARY_TODO entries; doc updates

- Broadened ACCORE-META-T-F8R3 to cover SKILL.md/registry text drift, not just summary staleness
- Added concrete SKILL.md drift examples and clarified fix direction
- Added BINARY_TODOs for Id detection (convention/attribute) and serializer-native ignore attribute
- Updated SIGNALR_BINARY_PROTOCOL_TODO.md and ADR 0001 to clarify deferral of decorator base/handshake TODOs
- Minor topic code length and JSON-in-Binary tech debt clarifications
- Synced references and cross-links with latest protocol decisions
2026-04-27 14:42:10 +02:00
..
README.md [LOADED_DOCS: 3 files, no new loads] 2026-04-27 14:42:10 +02:00
SIGNALR_DATASOURCE_ISSUES.md [LOADED_DOCS: 3 files, no new loads] 2026-04-27 14:42:10 +02:00
SIGNALR_DATASOURCE_TODO.md [LOADED_DOCS: 3 files, no new loads] 2026-04-27 14:42:10 +02:00

README.md

SignalR DataSource

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

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

Overview

AcSignalRDataSource is an abstract generic IList<T> that synchronizes with the server via CRUD tags. It handles change tracking, rollback, sync state, and pluggable Binary/JSON+GZip deserialization — so consuming code works with a regular list while the DataSource manages communication.

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:

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

Constructor:

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 that bundles 5 independent tag integers — one per CRUD operation. Tags are NOT sequential offsets; each tag is independently assigned:

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

Usage (consuming project — domain-agnostic example):

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) maps tracking state to the corresponding tag via switch expression.

Serializer Selection

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

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

Data Loading

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.PopulateMergeEndUpdate() (single batched UI notification).
    • AcSerializerType.Binary + plain IList<T>: AcBinaryDeserializer.Populate.
    • AcSerializerType.JsonGZip + IAcObservableCollection: GzipHelper.DecompressToStringPopulateFromJson.
    • AcSerializerType.JsonGZip + plain IList<T>: GzipHelper.DecompressToStringJsonTo.
  2. Typed object (responseData is TIList) — protocol already deserialized:

    • IAcObservableCollection: BeginUpdateClear + foreach AddEndUpdate.
    • 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 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, 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 an existing item as modified without replacing it — useful when properties are mutated in-place.

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

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

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

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

Useful when the UI already has a bound collection and you want the DataSource to manage it 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.Server)
Tracking helpers (JsonClone / ReflectionClone) SignalRs/TrackingItemHelpers.cs (in AyCode.Services.Server)
CRUD tags SignalRs/SignalRCrudTags.cs (in AyCode.Services)
Transport client SignalRs/AcSignalRClientBase.cs (in AyCode.Services)