Overhaul SignalR/DataSource docs, update all references

- Added SIGNALR.md (transport) and SIGNALR_DATASOURCE.md (collection) as layered, comprehensive documentation; retired SIGNALR_ARCHITECTURE.md
- Updated all .md files and READMEs to reference new docs and clarify separation between transport and DataSource
- Clarified CRUD tag structure (5 independent tags), single-method tag-based dispatch, and JSON-in-Binary tech debt
- Added slot allocation and wire format clarifications to serialization docs
- Improved documentation layering, conventions, and critical warnings for future maintainers
This commit is contained in:
Loretta 2026-03-29 18:28:52 +02:00
parent 0b27532f17
commit 03d606164c
14 changed files with 574 additions and 277 deletions

View File

@ -13,19 +13,21 @@
5. **AcBinary** — primary goal is **speed**. Two-phase scan+serialize, reference tracking, string interning.
6. **AcJson** — Newtonsoft.Json wrapper with $id/$ref, IId-based reference resolution, and chain API.
## Critical Warnings
7. **PasswordHasher** — PBKDF2-HMAC-SHA512. Do NOT modify the salt logic or iteration count — existing passwords become unverifiable.
8. **MeasuringStatus.Finnished** — intentional legacy typo in consuming projects. Do NOT fix the spelling.
9. **LogLevel enum values** — synchronized with database. Do NOT renumber.
## SignalR
10. **Single-method tag-based dispatch** — both directions use `OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)`. Tags are integers mapped via `[SignalR(tag)]` attributes. See [`docs/SIGNALR_ARCHITECTURE.md`](../docs/SIGNALR_ARCHITECTURE.md).
11. **JSON-in-Binary parameter serialization (⚠️ TEMPORARY)** — Client→server request parameters currently go through JSON→Binary→JSON round-trip (`SignalPostJsonDataMessage<T>.PostData` → `.ToJson()` → Binary envelope → server-side JSON parse). This is planned for replacement with pure Binary. **Do NOT attempt to fix this as a side effect of other work** — it requires coordinated client+server+consuming-project changes. Server→client responses already use pure Binary.
7. **Single-method transport** — all SignalR communication uses `OnReceiveMessage(tag, bytes, requestId)`. Tags are `int` constants resolved via `DynamicMethodRegistry`. Never add conventional hub methods.
8. **AcSignalRDataSource** — generic `IList<T>` with change tracking, CRUD via `SignalRCrudTags`, binary merge, rollback. See `docs/SIGNALR_DATASOURCE.md`. Transport docs: `docs/SIGNALR.md`.
9. **JSON-in-Binary tech debt** — client→server request parameters are currently JSON inside a Binary envelope (`SignalPostJsonDataMessage`). Do NOT attempt to fix as a side effect — requires coordinated changes across all consuming projects.
## Critical Warnings
10. **PasswordHasher** — PBKDF2-HMAC-SHA512. Do NOT modify the salt logic or iteration count — existing passwords become unverifiable.
11. **MeasuringStatus.Finnished** — intentional legacy typo in consuming projects. Do NOT fix the spelling.
12. **LogLevel enum values** — synchronized with database. Do NOT renumber.
## Conventions
12. Do not suggest removal/rollback as a solution — find a fix for the problem.
13. Extension methods over instance methods for CRUD operations (see DbSets pattern).
14. Session pattern for reads, Transaction pattern for writes (see DataLayers).
15. **Target framework is net9.0** (set in AyCode.Core.targets). The SourceGenerator targets netstandard2.0. Consuming projects (AyCode.Blazor, FruitBankHybridApp UI) may target net10.0 but reference AyCode.Core DLLs built as net9.0.
16. **No redundant code** — before writing new logic, search for existing methods. Reuse or extract shared logic into smaller methods rather than duplicating. If an existing method does most of what you need, split it into composable parts.
17. **Keep all .md files in sync** — when you modify code, update any affected .md file (README.md, docs/, GLOSSARY, ARCHITECTURE, CONVENTIONS, BINARY_FORMAT, SCHEMA, etc.). If you notice any .md content does not match the current code, fix it automatically.
13. Do not suggest removal/rollback as a solution — find a fix for the problem.
14. Extension methods over instance methods for CRUD operations (see DbSets pattern).
15. Session pattern for reads, Transaction pattern for writes (see DataLayers).
16. **Target framework is net9.0** (set in AyCode.Core.targets). The SourceGenerator targets netstandard2.0. Consuming projects (AyCode.Blazor, FruitBankHybridApp UI) may target net10.0 but reference AyCode.Core DLLs built as net9.0.
17. **No redundant code** — before writing new logic, search for existing methods. Reuse or extract shared logic into smaller methods rather than duplicating. If an existing method does most of what you need, split it into composable parts.
18. **Keep all .md files in sync** — when you modify code, update any affected .md file in the same area. If you already read an .md file during your work and notice it contradicts the current code, fix the discrepancy — but do NOT proactively scan or open .md files just to check for issues.
19. **Documentation layering** — write `.md` documentation at the **defining layer** (where the code lives). Higher-layer `.md` files reference the base docs (e.g. `see AyCode.Core/docs/SIGNALR.md`) and document only project-specific overrides or extensions. Never duplicate base-layer descriptions in consumer-level docs.

View File

@ -12,6 +12,15 @@ Targets **netstandard2.0** (required for Roslyn analyzers/generators).
- `ModuleInitializer` — Auto-registers all generated writers/readers at startup.
- Circular reference detection with `ACBIN001` diagnostic warning.
## Slot Allocation
Each generated writer reserves a unique type slot via `AcBinarySerializer.AllocateWrapperSlot()` (static field initializer, `Interlocked.Increment`).
- **Slots 063** — reserved for runtime polymorphic types (assigned dynamically on first encounter)
- **Slots 64+** — source-generated types (allocated at `[ModuleInitializer]` registration time)
**Slot indices are NOT stable across compilations.** The order depends on Roslyn's `ForAttributeWithMetadataName()` enumeration order, which may vary between builds. This is fine because slots are only meaningful within a single serialization/deserialization session — they are never persisted to disk or sent over the wire as slot indices (the wire format uses type names or metadata hashes for cross-session/cross-type compatibility).
## Feature Flags
The `[AcBinarySerializable]` attribute supports per-type feature control:

View File

@ -1,6 +1,6 @@
# AyCode.Core
Core library for the AyCode platform. Targets .NET 10. Provides serialization (Binary, JSON, Toon), compression, logging, validation, and shared utilities.
Core library for the AyCode platform. Targets .NET 9 (set in `AyCode.Core.targets`). Provides serialization (Binary, JSON, Toon), compression, logging, validation, and shared utilities.
## Folder Structure

View File

@ -23,6 +23,7 @@ The serializer is generic over `TOutput` for strategy selection (`ArrayBinaryOut
| 7690 | **Primitives** — Null, Bool, Int864, Float3264, Decimal, Char |
| 9194 | **Strings** — String, StringInterned, StringEmpty, StringInternFirst |
| 9598 | **Date/Time** — DateTime, DateTimeOffset, TimeSpan, Guid |
| 99102 | **Other** — Enum, Legacy headers, PropertySkip |
| 103134 | **FixStr** — Short strings with length encoded in marker |
| 144+ | **Headers** — Metadata, RefHandling, CacheCount flags |
| 192255 | **Tiny ints** — Single-byte encoding for values -16 to 47 |
@ -77,22 +78,13 @@ For the complete wire format specification (encoding rules, header format, inter
## Configuration Options
| Option | Type | Default | Wire format impact |
|---|---|---|---|
| `WireMode` | Compact/Fast | Compact | Compact: VarInt+UTF-8. Fast: fixed-width+UTF-16 memcpy |
| `ReferenceHandling` | None/OnlyId/All | All | Controls scan pass, `ObjectRefFirst(70)`/`ObjectRef(65)` markers |
| `UseMetadata` | bool | false | Adds FNV-1a property hashes, enables `ObjectWithMetadata(69)` markers |
| `UseStringInterning` | None/Attribute/All | Attribute | Controls `StringInternFirst(94)`/`StringInterned(92)` deduplication |
| `MaxDepth` | byte | 255 | Nested objects beyond depth written as `Null(76)` |
| `UseCompression` | None/Block/BlockArray | None | Post-serialization LZ4 compression (transparent wrapper) |
| `PropertyFilter` | delegate? | null | Exclude properties from stream entirely |
| `ThrowOnCircularReference` | bool | true | Cycle detection behavior when ref handling enabled |
Key options that change wire format: `WireMode` (Compact/Fast), `ReferenceHandling` (None/OnlyId/All), `UseMetadata`, `UseStringInterning` (None/Attribute/All), `MaxDepth`, `UseCompression`, `PropertyFilter`.
**Key rule:** `ReferenceHandling=None` + `UseStringInterning=None` = no scan pass (fastest, single-phase).
`ReferenceHandling=None` + `UseStringInterning=None` = no scan pass (fastest, single-phase).
**Presets:** `Default` (All refs, Attribute interning), `FastMode` (no refs, no interning), `ShallowCopy` (depth=0), `WasmOptimized` (+string caching).
Presets: `Default`, `FastMode`, `ShallowCopy`, `WasmOptimized`.
For detailed option documentation with code branches and interactions, see [`../../../../docs/BINARY_FORMAT.md`](../../../../docs/BINARY_FORMAT.md#configuration-options).
For detailed option documentation with wire format impact, code branches, and interactions, see [BINARY_FORMAT.md — Configuration Options](../../../../docs/BINARY_FORMAT.md#configuration-options).
## Dependencies

View File

@ -3,7 +3,7 @@
Reflection-based infrastructure for dynamically dispatching method calls by message tag, primarily used for SignalR message routing.
> **Context:** This is the server-side dispatch engine for the SignalR tag-based architecture.
> See [`docs/SIGNALR_ARCHITECTURE.md`](../../docs/SIGNALR_ARCHITECTURE.md) for the full message flow.
> See [`docs/SIGNALR.md`](../../docs/SIGNALR.md) for the full message flow.
## How It Fits

View File

@ -2,7 +2,7 @@
Server-side SignalR hub infrastructure: hub base class, session management, data source with change tracking, and client broadcast service.
> **Architecture:** For full dispatch flow, tag system, and tech debt documentation see [`docs/SIGNALR_ARCHITECTURE.md`](../../docs/SIGNALR_ARCHITECTURE.md).
> **Architecture:** For full dispatch flow, tag system, and tech debt documentation see [`docs/SIGNALR.md`](../../docs/SIGNALR.md).
## Key Files
@ -17,7 +17,17 @@ Server-side SignalR hub infrastructure: hub base class, session management, data
- **`AcSignalRSendToClientService.cs`** — Abstract broadcast service: `SendMessageToClient()`, `SendMessageToAllClients()`, `SendMessageToConnection()`, `SendMessageToUser()`.
### Data Source
- **`AcSignalRDataSource.cs`** — Server-side data source implementing `IList<T>` with change tracking (`TrackingItem<T, TId>`, `ChangeTracking<T, TId>`). Supports `LoadDataSource()`, `LoadDataSourceFromResponseData()` with binary/JSON deserialization.
> **Full specification:** [`docs/SIGNALR_DATASOURCE.md`](../../docs/SIGNALR_DATASOURCE.md)
- **`AcSignalRDataSource.cs`** — Generic real-time collection (`AcSignalRDataSource<TDataItem, TId, TIList>`) implementing `IList<T>` with full CRUD and change tracking.
- **Change tracking:** `TrackingItem<T, TId>` wraps each modified item with `TrackingState` + `OriginalValue` for rollback. `ChangeTracking<T, TId>` manages the tracking list.
- **Loading:** `LoadDataSource()` (sync), `LoadDataSourceAsync()` (async callback), `LoadItem(id)` (single). Binary path uses `BinaryToMerge()` for `AcObservableCollection` (batch UI update via `BeginUpdate/EndUpdate`).
- **Saving:** `SaveChanges()` iterates tracked items, posts each via CRUD tag, rollbacks on failure. `SaveItem()` for individual saves.
- **Sync state:** `IsSyncing` (Interlocked counter) + `OnSyncingStateChanged` event for UI loading indicators.
- **Locking:** `object _syncRoot` (sync ops) + `SemaphoreSlim _asyncLock` (async ops). `GetEnumerator()` returns safe copy.
- **Working reference list:** `SetWorkingReferenceList()` allows external list to become inner storage (zero-copy).
- **Context:** `ContextIds` (object[]) + `FilterText` (string) sent with GetAll requests for server-side filtering.
### Utilities
- **`ExtensionMethods.cs`** — `InvokeMethod()` — invokes methods and unwraps `Task`/`Task<T>`/`ValueTask` results.

View File

@ -2,7 +2,7 @@
Custom binary SignalR protocol, client infrastructure, message tagging, and serialization helpers.
> **Architecture:** For full dispatch flow, tag system, and tech debt documentation see [`docs/SIGNALR_ARCHITECTURE.md`](../../docs/SIGNALR_ARCHITECTURE.md).
> **Architecture:** For full dispatch flow, tag system, and tech debt documentation see [`docs/SIGNALR.md`](../../docs/SIGNALR.md).
## Key Files
@ -17,7 +17,7 @@ Custom binary SignalR protocol, client infrastructure, message tagging, and seri
### Message Tagging
- **`SignalMessageTagAttribute.cs`** — Three attributes: `TagAttribute` (base, int messageTag), `SignalRAttribute` (server method routing + client notification), `SignalRSendToClientAttribute` (client-side receive).
- **`AcSignalRTags.cs`** — Static constants: `None`, `PingTag`, `EchoTag`.
- **`SignalRCrudTags.cs`** — Sealed record mapping 5 CRUD tags. `GetMessageTagByTrackingState()` maps `TrackingState` → tag.
- **`SignalRCrudTags.cs`** — Sealed class bundling 5 independent CRUD tag integers. `GetMessageTagByTrackingState()` maps `TrackingState` → tag. See [`docs/SIGNALR_DATASOURCE.md`](../../docs/SIGNALR_DATASOURCE.md).
- **`SendToClientType.cs`** — Enum: None, Others, Caller, All.
### Serialization & Pooling

View File

@ -36,7 +36,7 @@ AyCode.Services ← AyCode.Services.Server
- **AyCode.Services.Server** — Server-side: SignalR hub with custom binary protocol, email (SendGrid), JWT auth.
- **AyCode.Models.Server/DynamicMethods** — Reflection-based tag→method dispatch used by the SignalR hub.
> **SignalR Dispatch:** Both directions use a single method `OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)` with integer tag-based routing instead of standard Hub methods. See [`SIGNALR_ARCHITECTURE.md`](SIGNALR_ARCHITECTURE.md) for full details.
> **SignalR Dispatch:** Both directions use a single method `OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)` with integer tag-based routing instead of standard Hub methods. See [`SIGNALR.md`](SIGNALR.md) for full details.
### Server Extensions
- **AyCode.Core.Server**, **AyCode.Interfaces.Server**, **AyCode.Entities.Server**, **AyCode.Models.Server** — Server-only additions that don't belong in shared code.

View File

@ -210,6 +210,23 @@ Prevents infinite loops and preserves object identity for repeated references.
- First occurrence: `[ObjectRefFirst(70)] [VarUInt refCacheIndex] [object body...]`
- Subsequent: `[ObjectRef(65)] [VarUInt refCacheIndex]`
**Example — same object referenced twice:**
```
Input: { Users: [userA, userA] } (same instance)
Scan pass → WritePlan:
[{VisitIndex:2, CacheMapIndex:0, IsFirst:true},
{VisitIndex:3, CacheMapIndex:0, IsFirst:false}]
Wire output (Compact mode, ReferenceHandling=All):
[version=1] [flags=0x96] [VarUInt cacheCount=1] ← header
[FixObj(0)] ← root object
[Array(66)] [VarUInt(2)] ← Users array, 2 elements
[ObjectRefFirst(70)] [VarUInt(0)] [props...] ← userA, 1st occurrence
[ObjectRef(65)] [VarUInt(0)] ← userA, 2nd (2 bytes only)
```
## Property Ordering
Properties are serialized in a deterministic order defined by `TypeMetadataBase.GetUnfilteredProperties()`:
@ -231,6 +248,11 @@ When `UseMetadata=true`, property name hashes (FNV-1a via `FnvHash.ComputeString
When `UseMetadata=false`, properties are matched by **positional index only** — source and destination must have identical property layouts.
**Edge cases:**
- **Hash collision** (`CheckDuplicatePropName=true`, default): throws `InvalidOperationException`. When `false`: collision silently ignored — risk of data corruption.
- **Source has unknown property** (not in destination): silently skipped via `SkipValue()`, no error.
- **Destination has extra property** (not in source): left at default value (new instance) or unchanged (populate mode).
## Configuration Options
Options defined in `AcBinarySerializerOptions` (inherits `AcSerializerOptions`). Each option controls which code paths execute and how the wire format changes.

View File

@ -24,11 +24,11 @@
## SignalR Conventions
See [`SIGNALR_ARCHITECTURE.md`](SIGNALR_ARCHITECTURE.md) for full architecture documentation.
See [`SIGNALR.md`](SIGNALR.md) for full architecture documentation.
- **Single dispatch method** — all communication goes through `OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)`. Do not add new hub methods.
- **Tag-based routing** — associate methods with integer tags via `[SignalR(tag)]` (server) or `[SignalRSendToClient(tag)]` (client). Tags must be unique across the entire system.
- **CRUD bundles** — entities use `SignalRCrudTags(baseTag)` which allocates 5 sequential tags (GetAll, GetItem, Add, Update, Remove). Reserve non-overlapping base tags.
- **CRUD bundles** — entities use `SignalRCrudTags(getAllTag, getItemTag, addTag, updateTag, removeTag)` with 5 independent tag integers. Tags must be unique across the system. See [`SIGNALR_DATASOURCE.md`](SIGNALR_DATASOURCE.md).
- **Binary protocol**`AcBinaryHubProtocol` is the transport protocol. Responses use pure Binary serialization.
### ⚠️ Temporary: JSON-in-Binary Request Parameters
@ -46,17 +46,3 @@ This is **planned for replacement** with direct Binary parameter serialization (
- **Abstract test bases** with `AcBase_` prefixed methods for reusable test logic.
- **TestDataFactory** for centralized test data creation with ID sequencing.
- **Testable infrastructure** for SignalR: `TestableSignalRClient2`, `TestableSignalRHub2` bypass real connections.
## Code Reuse
- Before writing new code, search the codebase for existing implementations.
- If a method does most of what you need, extract the shared part into a smaller reusable method rather than copying and modifying.
- Prefer composing existing helpers over creating parallel implementations.
- When adding a variation of existing logic, refactor the original into composable pieces that both call sites can use.
## Critical Rules
- **Never modify PasswordHasher salt/iteration logic** — breaks existing password verification.
- **Never renumber LogLevel enum values** — synchronized with database.
- **Never fix "Finnished" spelling** — intentional legacy typo in consuming projects.
- **Never suggest removal as a solution** — find a fix instead.

View File

@ -64,23 +64,27 @@ For full specification see [`BINARY_FORMAT.md`](BINARY_FORMAT.md).
| **Session** | Read-only database operation pattern. No transaction, no mutex lock on DbContext. |
| **Transaction** | Write operation pattern with auto-rollback on failure. |
## SignalR Dispatch
## SignalR Infrastructure
For full architecture see [`SIGNALR_ARCHITECTURE.md`](SIGNALR_ARCHITECTURE.md).
For full architecture see [`SIGNALR.md`](SIGNALR.md).
| Term | Definition |
|---|---|
| **OnReceiveMessage** | The single SignalR method used for all communication in both directions. Signature: `(int messageTag, byte[] messageBytes, int? requestId)`. |
| **Message Tag** | Integer identifier that maps to a specific method via `[SignalR(tag)]` or `[SignalRSendToClient(tag)]` attributes. |
| **TagAttribute** | Base attribute associating an integer tag with a method. `SignalRAttribute` and `SignalRSendToClientAttribute` extend it. |
| **DynamicMethodRegistry** | Reflection-based registry that resolves message tags to `MethodInfo` at runtime. Uses `ConcurrentDictionary` + `FrozenDictionary` caching. |
| **AcDynamicMethodCallModel** | Per-instance-type method cache. Builds a `FrozenDictionary<int, AcMethodInfoModel>` once per type via reflection. |
| **SignalRCrudTags** | Sealed record mapping 5 sequential tags (GetAll, GetItem, Add, Update, Remove) for entity CRUD. `GetMessageTagByTrackingState()` maps `TrackingState` → tag. |
| **OnReceiveMessage** | The single SignalR method for all communication. Signature: `(int messageTag, byte[] messageBytes, int? requestId)`. |
| **Message Tag** | Integer identifier mapping to a method via `[SignalR(tag)]` or `[SignalRSendToClient(tag)]` attributes. |
| **DynamicMethodRegistry** | Resolves message tags to `MethodInfo` at runtime. Static `ConcurrentDictionary` cache with lazy scan on miss. |
| **SignalRCrudTags** | Sealed class bundling 5 independent tag integers (getAllTag, getItemTag, addTag, updateTag, removeTag) for entity CRUD. See [`SIGNALR_DATASOURCE.md`](SIGNALR_DATASOURCE.md). |
| **AcBinaryHubProtocol** | Custom `IHubProtocol` replacing SignalR's JSON+Base64 with `AcBinarySerializer`. Protocol name: `"acbinary"`. |
| **SignalPostJsonDataMessage\<T\>** | ⚠️ TEMPORARY — Message that serializes request parameters to JSON inside a Binary envelope. Planned for replacement with pure Binary. |
| **SignalResponseDataMessage** | Response message supporting Binary or JSON+GZip serialization. Responses already use pure Binary (no JSON overhead). |
| **AcSignalRDataSource** | Server-side `IList<T>` with `ChangeTracking` and SignalR CRUD via `SignalRCrudTags`. Supports binary merge for incremental updates. |
| **SignalResponseDataMessage** | Response message supporting Binary or JSON+GZip. Responses use pure Binary (no JSON overhead). |
| **SignalPostJsonDataMessage** | ⚠️ TECH DEBT — request params serialized to JSON inside Binary envelope. Planned for pure Binary replacement. |
| **AcSignalRDataSource** | Generic real-time `IList<T>` with change tracking, CRUD via SignalRCrudTags, binary merge, rollback, sync state. |
| **TrackingItem** | Wraps a modified DataSource item with `TrackingState` (Add/Update/Remove) + `OriginalValue` for rollback. |
| **SendToClientType** | Enum controlling broadcast scope: None, Others, Caller, All. |
| **AcWebSignalRHubBase** | Abstract server hub. Receives `OnReceiveMessage`, dispatches via DynamicMethodRegistry, broadcasts to other clients. |
| **AcSignalRClientBase** | Abstract client. Manages `HubConnection`, request/response correlation via `requestId`, pooled `SignalRRequestModel`. |
| **AcSessionService** | `ConcurrentDictionary`-based session tracker for connected SignalR clients. |
| **AcSignalRSendToClientService** | Server-push service: `SendMessageToAllClients`, `SendMessageToConnection`, `SendMessageToUser`. |
| **Working Reference List** | DataSource feature: `SetWorkingReferenceList()` allows external list to become inner storage (zero-copy). |
## Logging

281
docs/SIGNALR.md Normal file
View File

@ -0,0 +1,281 @@
# SignalR Communication
The transport layer for AyCode real-time communication. Source: `AyCode.Services/SignalRs/` (client), `AyCode.Services.Server/SignalRs/` (server).
> For the change-tracked collection built on top of this transport, see [`SIGNALR_DATASOURCE.md`](SIGNALR_DATASOURCE.md).
## Design Overview
All communication flows through a **single hub method** with **tag-based dispatch**:
```
Client ──OnReceiveMessage(tag, bytes, requestId)──► Server
Client ◄──OnReceiveMessage(tag, bytes, requestId)── Server
```
`OnReceiveMessage` is the only transport channel — all calls go through it. The `tag` (int) determines **which server method to invoke**. The `DynamicMethodRegistry` maps the tag to a concrete `[SignalR(tag)]`-annotated method at runtime. Projects call any endpoint by specifying the tag; the server dispatches accordingly.
```
Client side: Server side:
───────────────────── ─────────────────────
AcSignalRClientBase AcWebSignalRHubBase<TTags, TLogger>
├─ HubConnection (WebSocket) ├─ Hub<IAcSignalRHubItemServer>
├─ AcBinaryHubProtocol ├─ DynamicMethodRegistry
├─ Pending request tracking ├─ Parameter deserialization
└─ Response callbacks └─ Broadcast to other clients
```
## Tag System
### Tag Definition
AyCode.Core defines only the base class and built-in infrastructure tags:
```csharp
public class AcSignalRTags
{
public const int None = 0;
public const int PingTag = 90001;
public const int EchoTag = 90002;
}
```
**Consuming projects** define their own tags by inheriting `AcSignalRTags`:
```csharp
public abstract class MyProjectTags : AcSignalRTags
{
public const int GetOrders = 100;
public const int GetOrderById = 101;
public const int SaveOrder = 102;
// ... project-specific tags
}
```
Tags are plain `int` constants. The project decides the numbering. Tags must be unique across all registered services within a hub.
### Tag Attributes
Three attribute levels:
```csharp
// Base: maps an integer tag to a method
[Tag(42)]
// Server method: tag + optional broadcast behavior
[SignalR(messageTag: 100, sendToOtherClientTag: 100, sendToOtherClientType: SendToClientType.Others)]
// messageTag: incoming tag this method handles
// sendToOtherClientTag: tag used when broadcasting result to other clients (can differ from messageTag)
// sendToOtherClientType: who receives the broadcast
// Client receive: marks method for server→client dispatch
[SignalRSendToClient(100)]
```
**SendToClientType:** `None` (no broadcast), `Others` (all except caller), `Caller` (response only), `All` (everyone).
### How Projects Use Tags
Projects call the SignalR transport directly — not only through DataSource:
```csharp
// Direct call: project code calls any endpoint by tag
var orders = await signalRClient.PostAsync<List<Order>>(MyTags.GetOrders, companyId);
var order = await signalRClient.GetByIdAsync<Order>(MyTags.GetOrderById, orderId);
await signalRClient.PostDataAsync(MyTags.SaveOrder, order);
// Async callback (non-blocking)
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, async response => { ... });
// Fire-and-forget
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, response => { ... });
```
The `AcSignalRClientBase` CRUD helpers (`PostAsync`, `GetByIdAsync`, `GetAllAsync`, `PostDataAsync`) are all generic transport methods that work with any tag — they are not tied to DataSource.
## Dynamic Method Dispatch
See also: [`AyCode.Models.Server/DynamicMethods/README.md`](../AyCode.Models.Server/DynamicMethods/README.md)
### Server-Side Lookup
```
1. OnReceiveMessage(tag=100, bytes, requestId)
2. DynamicMethodRegistry.GetMethodByMessageTag(100)
├─ Check static ConcurrentDictionary<int, (Type, AcMethodInfoModel)?> cache
├─ Hit? → find instance of cached Type from registered instances
├─ Miss? → scan all registered instances' methods for [SignalR(100)]
│ cache the result (including negative = null)
└─ Return (instance, methodInfoModel) or null
3. AcMethodInfoModel contains:
├─ MethodInfo (the method to invoke)
├─ SignalRAttribute (tag, sendToOtherClientTag, sendToOtherClientType)
└─ ParamInfos[] (ParameterInfo for deserialization)
```
The `DynamicMethodRegistry` uses a static `ConcurrentDictionary` for the global tag→method cache. Note: `AcDynamicMethodCallModel` uses a `FrozenDictionary` per-type cache internally, but the registry's own lookup path does direct method scanning with `ConcurrentDictionary` caching.
### Registration
The hub registers service instances during initialization:
```csharp
DynamicMethodRegistry.Register(myService); // scans [SignalR(tag)] methods lazily
DynamicMethodRegistry.Register(anotherService);
```
Reflection runs lazily per tag on first request, then results are cached statically.
## Wire Protocol
### AcBinaryHubProtocol
Custom `IHubProtocol` (name: `"acbinary"`), replaces default JSON. Frame format:
```
[4 bytes: payload length, little-endian] [1 byte: message type] [payload...]
```
Message types: Invocation(1), StreamItem(2), Completion(3), Ping(6), Close(7), Ack(8), Sequence(9).
Arguments serialized individually with VarUInt length prefix. Direct write to `IBufferWriter` via `BufferWriterBinaryOutput`.
### Response Message
`SignalResponseDataMessage` carries:
| Field | Type | Purpose |
|-------|------|---------|
| `MessageTag` | int | Tag identifying the operation |
| `Status` | SignalResponseStatus | Success/Error |
| `ResponseData` | byte[] | Binary-serialized payload (or GZip+JSON fallback) |
| `DataSerializerType` | AcSerializerType | Binary or Json |
**Binary mode** (default): `AcBinarySerializer.ToBinary(data)` → raw bytes.
**JSON fallback**: `ToJson(data)``GzipHelper.Compress(json)` → bytes.
## Request/Response Flow
### Client → Server
```
1. PostAsync<T>(tag, postData) or PostDataAsync(tag, data, callback)
2. CreatePostMessage(postData):
├─ Primitives/strings/enums/value types → IdMessage (JSON array of serialized values)
└─ Complex objects → SignalPostJsonDataMessage<T> ⚠️ tech debt: JSON-in-Binary
3. SerializeToBinary(message) wraps in Binary envelope
4. HubConnection.SendAsync("OnReceiveMessage", tag, bytes, requestId)
5. AcBinaryHubProtocol frames it on the wire
```
### Server Processing
```
6. OnReceiveMessage(tag, bytes, requestId)
7. DynamicMethodRegistry.GetMethodByMessageTag(tag) ← ConcurrentDictionary lookup
8. DeserializeParameters(bytes):
├─ DeserializeFromBinary<SignalPostJsonMessage>() ← unwrap Binary envelope
├─ IdMessage format? → parse each Ids[i] as JSON per parameter type
└─ Complex object? → json.JsonTo(paramType) ⚠️ tech debt: JSON parse
9. MethodInfo.InvokeMethod(instance, params) ← unwraps Task/ValueTask
10. CreateResponseMessage(tag, Success, result) ← pure Binary serialization
11. ResponseToCaller(tag, message, requestId)
12. If SendToOtherClientType != None:
└─ SendMessageToOthers(sendToOtherClientTag, result) ← uses sendToOtherClientTag, not messageTag
```
### Server → Client
```
13. Client.OnReceiveMessage(tag, bytes, requestId)
14. Has matching requestId in pending ConcurrentDictionary?
├─ YES (response to own request):
│ 15. DeserializeFromBinary<SignalResponseDataMessage>(bytes)
│ 16. Route based on pending request type:
│ ├─ null (sync wait) → set ResponseByRequestId, WaitToAsync completes
│ ├─ Action<SignalResponseDataMessage> → invoke directly
│ └─ Func<SignalResponseDataMessage, Task> → invoke and await
│ 17. GetResponseData<T>():
│ ├─ Binary: ResponseData.BinaryTo<T>()
│ └─ JSON: GzipDecompress → AcJsonDeserializer.Deserialize<T>()
└─ NO (broadcast from another client's action):
18. abstract MessageReceived(tag, bytes).Forget()
└─ Consuming project overrides to handle server push
```
**Broadcast receive:** When the server broadcasts to `Others`/`All`, receiving clients have no matching `requestId`. The message falls through to `MessageReceived(int messageTag, byte[] messageBytes)` — an abstract method the consuming project overrides to handle unsolicited server pushes (e.g., refresh UI, update local state).
**Request model pooling:** `SignalRRequestModel` instances are managed via `SignalRRequestModelPool` (ObjectPool<T> + IResettable) to avoid allocations per request.
### Response Patterns
| Pattern | Method | Blocking |
|---------|--------|----------|
| Sync wait | `PostAsync<T>(tag, data)` | Yes, polls up to `TransportSendTimeout` (60s) |
| Async callback | `PostDataAsync(tag, data, async msg => {...})` | No, `Func<SignalResponseDataMessage, Task>` |
| Fire-and-forget | `PostDataAsync(tag, data, msg => {...})` | No, `Action<SignalResponseDataMessage>` |
## Session Management
`AcSessionService<TSessionItem, TSessionItemId>` tracks connected clients:
```csharp
ConcurrentDictionary<TSessionItemId, TSessionItem> Sessions
```
`IAcSessionItem<TSessionItemId>` requires `SessionId` property. Used for targeting messages to specific users/connections.
## Broadcast Service
`AcSignalRSendToClientService<THub, TTags, TLogger>` provides server-push methods:
| Method | Target |
|--------|--------|
| `SendMessageToAllClients` | All connected |
| `SendMessageToConnection(connectionId)` | Single connection |
| `SendMessageToUser(userId)` | User (all connections) |
| `SendMessageToUsers(userIds)` | Multiple users |
All messages wrapped in `SignalResponseDataMessage` → binary serialized → `OnReceiveMessage`.
## Connection Lifecycle
**Client configuration:**
- Transport: WebSockets only (`SkipNegotiation = true`)
- Buffer: 30MB transport + 30MB application
- Reconnection: `WithAutomaticReconnect()` + `WithStatefulReconnect()`
- Keepalive: 60s interval, 180s server timeout
**Hub events:**
- `OnConnectedAsync()` — log connection
- `OnDisconnectedAsync(exception)` — log disconnection, cleanup session
## Diagnostics
Enable with `AcSignalRClientBase.EnableBinaryDiagnostics = true` or `AcWebSignalRHubBase.EnableBinaryDiagnostics = true`.
Logs: hex dump (500 byte sample), header parsing (version, marker), property count + names via VarUInt reading.
`SignalResponseDataMessage.DiagnosticLogger` — per-response logging: target type info, property list, inheritance chain, hex dump.
## Known Technical Debt
**JSON-in-Binary request parameters:** Client→server requests currently wrap parameters in JSON inside the binary envelope (`SignalPostJsonDataMessage`). This adds an unnecessary serialization round-trip. Responses are already pure binary. Do NOT attempt to fix as a side effect — requires coordinated changes across all consuming projects.
## Key Source Files
| Component | Path |
|-----------|------|
| Hub base | `AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs` |
| Client base | `AyCode.Services/SignalRs/AcSignalRClientBase.cs` |
| Binary protocol | `AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` |
| Message types | `AyCode.Services/SignalRs/IAcSignalRHubClient.cs` |
| Serialization | `AyCode.Services/SignalRs/SignalRSerializationHelper.cs` |
| Tag attributes | `AyCode.Services/SignalRs/SignalMessageTagAttribute.cs` |
| Base tags | `AyCode.Services/SignalRs/AcSignalRTags.cs` |
| Session service | `AyCode.Services.Server/SignalRs/AcSessionService.cs` |
| Broadcast service | `AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs` |
| Dynamic dispatch | `AyCode.Models.Server/DynamicMethods/AcDynamicMethodRegistry.cs` |

View File

@ -1,217 +0,0 @@
# SignalR Architecture
> This is the core communication layer of the AyCode framework.
> The dispatch mechanism is **not self-evident** — read this document before modifying SignalR-related code.
## Overview
AyCode uses a **single-method, tag-based RPC dispatch** pattern over SignalR instead of the standard Hub method-per-endpoint approach.
Both client→server and server→client communication go through **one method**:
```
OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
```
The `messageTag` integer determines which method to invoke. The `messageBytes` contain the serialized parameters (Binary envelope). The optional `requestId` enables request/response correlation.
## Why Not Standard SignalR Hub Methods?
Standard SignalR hubs expose one C# method per endpoint. This approach:
- Requires maintaining proxy interfaces on both sides
- Makes it hard to dynamically register/unregister endpoints
- Adds friction for CRUD-heavy data sources (5 methods × N entities)
The tag-based approach:
- **Single transport method** — no interface synchronization needed
- **Dynamic dispatch** — methods discovered via reflection + attributes at startup
- **CRUD bundles**`SignalRCrudTags` maps 5 tags per entity automatically
- **Binary protocol**`AcBinaryHubProtocol` replaces JSON+Base64 with `AcBinarySerializer`
## Message Flow
### Client → Server (Request)
```
1. Client calls PostAsync<TResponse>(tag, postData)
└─ AcSignalRClientBase.cs
2. CreatePostMessage() wraps postData:
├─ IdMessage — for primitive IDs (Guid, int, string)
└─ SignalPostJsonDataMessage<T> — for complex objects
└─ PostData setter → PostDataJson = _postData.ToJson() ⚠️ TECH DEBT
└─ Result serialized to Binary via SignalRSerializationHelper.SerializeToBinary()
3. SendMessageToServerAsync(tag, binaryBytes, requestId)
└─ HubConnection.InvokeAsync("OnReceiveMessage", tag, bytes, reqId)
4. AcBinaryHubProtocol encodes as raw binary frame (not JSON+Base64)
```
### Server Processing
```
5. AcWebSignalRHubBase.OnReceiveMessage(tag, bytes, requestId)
6. ProcessOnReceiveMessage():
├─ TryFindAndInvokeMethod(tag) via DynamicMethodRegistry
│ ├─ Static ConcurrentDictionary cache (per tag)
│ └─ FrozenDictionary per-instance-type (built once, immutable)
├─ DeserializeParameters(methodInfo, bytes):
│ ├─ DeserializeFromBinary<SignalPostJsonMessage>() — unwrap Binary envelope
│ ├─ Extract PostDataJson string ⚠️ TECH DEBT
│ └─ AcJsonDeserializer.Deserialize() per parameter ⚠️ TECH DEBT
└─ Invoke method with deserialized parameters
```
### Server → Client (Response)
```
7. Method returns result
8. CreateResponseMessage(tag, result, serializerOptions):
└─ SignalResponseDataMessage serializes result:
├─ Binary mode: AcBinarySerializer directly (no JSON step)
└─ JSON mode: JSON + GZip compression
9. ResponseToCaller(responseMessage)
└─ Clients.Caller.OnReceiveMessage(tag, bytes, requestId)
```
### Client Response Processing
```
10. AcSignalRClientBase.OnReceiveMessage(tag, bytes, requestId)
├─ Lookup pending request by requestId
├─ DeserializeFromBinary<SignalResponseDataMessage>()
└─ Complete TaskCompletionSource → caller gets TResponse
11. SignalResponseDataMessage.GetResponseData<T>():
├─ Binary: ResponseData.BinaryTo<T>() — direct, no JSON
└─ JSON: GZip decompress → AcJsonDeserializer (pooled buffer)
```
## ⚠️ Technical Debt: JSON-in-Binary Parameter Serialization
**Status:** Temporary — planned for replacement with pure Binary serialization.
**Current behavior:** Request parameters (client→server) go through a JSON→Binary→JSON round-trip:
| Step | Location | What happens |
|---|---|---|
| 1 | `SignalPostJsonDataMessage<T>` setter | `PostDataJson = _postData.ToJson()` — object → JSON string |
| 2 | `SignalRSerializationHelper.SerializeToBinary()` | JSON string wrapped in Binary envelope |
| 3 | `AcWebSignalRHubBase.DeserializeParameters()` | Binary → `SignalPostJsonMessage``PostDataJson` → JSON parse per param |
**Why it exists:** The Binary serializer was added after the SignalR layer was built. Responses were migrated to pure Binary, but request parameters still use the original JSON path.
**Impact:** Extra serialization overhead on every client→server request. Responses are already pure Binary (no JSON overhead).
**Planned fix:** Replace `SignalPostJsonDataMessage<T>` with direct Binary parameter serialization, matching how responses already work.
**Key files involved:**
- `AyCode.Services/SignalRs/IAcSignalRHubClient.cs``SignalPostJsonDataMessage<T>.PostData` setter (line ~89)
- `AyCode.Services/SignalRs/AcSignalRClientBase.cs``CreatePostMessage()` (line ~220)
- `AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs``DeserializeParameters()` (line ~340)
> **Rule:** Do NOT attempt to fix the JSON-in-Binary pattern as a side effect of other work.
> It requires coordinated changes across client, server, and all consuming projects.
## Tag System
### Tag Attributes
```csharp
// Base: associates an integer tag with a method
[Tag(42)]
// Server method: tag + optional client notification behavior
[SignalR(42, SendToClientType.Others)]
// Client receive: marks method for server→client dispatch
[SignalRSendToClient(42)]
```
### Built-in Tags
| Tag | Constant | Purpose |
|---|---|---|
| 0 | `AcSignalRTags.None` | No tag / unset |
| 90001 | `AcSignalRTags.PingTag` | Keep-alive ping |
| 90002 | `AcSignalRTags.EchoTag` | Echo test |
### CRUD Tag Bundles
`SignalRCrudTags` maps 5 sequential tags per entity:
```csharp
var tags = new SignalRCrudTags(300); // base tag
// tags.GetAll = 300
// tags.GetItem = 301
// tags.AddTag = 302
// tags.UpdateTag = 303
// tags.RemoveTag = 304
```
`GetMessageTagByTrackingState()` maps `TrackingState` → appropriate tag for automatic CRUD dispatch.
## Dynamic Method Registry
See also: [`AyCode.Models.Server/DynamicMethods/README.md`](../AyCode.Models.Server/DynamicMethods/README.md)
### Lookup Flow
```
1. OnReceiveMessage(tag=42, ...)
2. DynamicMethodRegistry.TryFindMethod(tag)
├─ Check static ConcurrentDictionary<int, AcMethodInfoModel> cache
├─ Miss? Scan registered instances:
│ └─ AcDynamicMethodCallModel.GetMethodByTag(tag)
│ └─ FrozenDictionary<int, AcMethodInfoModel> (built once per type)
└─ Cache result for future lookups
3. AcMethodInfoModel contains:
├─ MethodInfo (reflection)
├─ TagAttribute (tag value, metadata)
└─ ParamInfos[] (parameter types for deserialization)
```
### Caching Strategy
- **Per-type:** `FrozenDictionary` built once via `ConcurrentDictionary.GetOrAdd()` — immutable after creation
- **Global:** `ConcurrentDictionary<int, AcMethodInfoModel>` — populated lazily per tag
- **Instance resolution:** Registry tracks which instance owns which tag for invocation
## DataSource Pattern
`AcSignalRDataSource<T, TId, TIList>` provides a server-side `IList<T>` with:
- **Change tracking:** `ChangeTracking<T, TId>` wraps items in `TrackingItem<T, TId>` (Added/Modified/Deleted/Unchanged)
- **SignalR CRUD:** Uses `SignalRCrudTags` for automatic tag mapping
- **Binary merge:** `BinaryToMerge()` deserializes and applies changes to the local list
- **Load from response:** `LoadDataSourceFromResponseData()` populates from `SignalResponseDataMessage`
## Binary Hub Protocol
`AcBinaryHubProtocol` (`IHubProtocol`) replaces SignalR's default JSON protocol:
- All 9 SignalR message types handled (Invocation, StreamItem, Completion, Ping, Close, CancelInvocation, StreamInvocation, Ack, Sequence)
- Inner `SpanReader` ref struct for zero-allocation parsing
- `TransferFormat.Binary` — no Base64 encoding needed
- Protocol name: `"acbinary"`
## Testing Infrastructure
Test classes bypass real SignalR connections:
| Class | Purpose |
|---|---|
| `TestableSignalRClient2` | Client that calls hub directly (no network) |
| `TestableSignalRHub2` | Hub that processes messages without real `HubCallerContext` |
| `TestSignalRTags` | 100+ tag constants for test scenarios |
| `TestSignalRService2` | `[SignalR(tag)]` attributed test methods |
Test data flows through the full serialization pipeline (Binary envelope, tag dispatch, parameter deserialization) — only the network transport is bypassed.

208
docs/SIGNALR_DATASOURCE.md Normal file
View File

@ -0,0 +1,208 @@
# 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 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
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) |