Firebase-style reactive queries and live sync, native to C#/.NET, for MongoDB, Postgres, and SQL Server.
Pulse is a .NET package suite that lets clients subscribe to a query (source + filter), receive an initial snapshot of matching documents, and then stay in sync as the database changes — MongoDB change streams, Postgres triggers, and SQL Server change tracking on the server, delivered to clients over SignalR.
Target framework: .NET 8 (LTS, MAUI-compatible) for all projects.
Status: v0.3 — SQL Server provider shipped. The v0.1 MVP steps 0–9 are done: scaffold,
Pulse.Abstractions,Pulse.Mongo's change-streamWatchAsync(verified end-to-end against a real Testcontainers Mongo replica set),Pulse.Server'sPulseHubbroadcast with shared per-source watches andAddMongoSource, server-side filter matching (DictionaryFilterMatcher), gap-free snapshot delivery on subscribe (GetSnapshotAsyncwith watch-first as-of token capture), resume-token persistence (IResumeTokenStore, in-memory + file-based, with stale-token resync verified across a server restart), thePulse.ClientSDK'sSubscribe<T>(typed snapshot + live changes,Currentcache, in-order message processing), automatic client reconnect with resubscribe-treats-as-fresh-snapshot, and match-transition handling (an update that flips a doc out of a filter becomes a synthetic delete; one that flips it in becomes an insert). v0.2 adds a second provider:Pulse.Postgreswatches tables via triggers plus apulse._changeslog (single-column primary key required,_id= pk), with the same resume-token, snapshot, and match-transition semantics — verified end-to-end against a real Testcontainers Postgres. v0.3 adds a third provider:Pulse.SqlServerwatches tables via native Change Tracking (CHANGETABLE(CHANGES ...)+ version-based resume tokens; single-column primary key required,_id= pk), verified end-to-end against a real Testcontainers SQL Server. API shapes below are the design contract and will land in that order.
- Why Pulse
- Features
- Non-goals for v0.1
- How it works
- Package layout
- Filter DSL
- Wire protocol
- Server integration
- Client SDK
- Resume tokens and gapless delivery
- Match-transition handling
- Postgres provider (v0.2)
- SQL Server provider (v0.3)
- Important caveats
- Development
- Testing
- Build status
- Roadmap / explicitly deferred
Many C# apps end up polling the database to reflect state changes in the UI, or hand-rolling a pub/sub layer that drifts from the data. Pulse treats the database as the source of truth and pushes changes out, so a client's view stays correct without a query being re-run on every change.
Pulse is designed to be provider-agnostic from day one: the core abstractions carry no database concepts, so the Postgres provider (v0.2) and the SQL Server provider (v0.3) were added without touching the server hub, the subscription registry, or the client SDK.
- Filter-based subscriptions — subscribe to a source (collection/table) with a filter expression; only matching change events are delivered.
- Snapshot + live diffs — on subscribe, the client receives the current matching documents, then incremental changes.
- Resume-token persistence — a server restart or brief outage does not silently drop events.
- Automatic reconnect + resubscribe — handled by the client SDK.
- Multi-provider — MongoDB (v0.1), Postgres (v0.2), and SQL Server (v0.3) behind the same
IChangeSource/IFilterMatcherseam. - Live list semantics — updates that move a document in or out of a filter's match set are surfaced correctly (synthetic insert/remove to each affected subscriber).
These are explicitly deferred and not built in v0.1:
- Offline-first sync / conflict resolution on mobile.
- Auth/authorization framework (v0.1 exposes the
IPulseAuthorizerseam only; the default allows everything — see Important caveats). - Multi-database support (Mongo only for v0.1, Postgres added in v0.2, SQL Server in v0.3; the interfaces must not leak provider-specific concepts).
- Horizontal scale-out across multiple server instances.
- Arbitrary pub/sub / message-queue behavior — this is DB-state-change subscription only.
Mongo (change streams) ──┐
├─ IChangeSource.WatchAsync / GetSnapshotAsync
Postgres (triggers + │
pulse._changes log) ───┼─┐
│ │
SQL Server (change ┘ │
tracking) ──────────────┘
▼
Pulse.Server ── SubscriptionRegistry (match-transition detection, resume tokens)
│ SignalR Hub /pulse
▼
PulseClient ── IPulseSubscription<T> (snapshot + live changes, local cache)
- A client calls
Subscribe(source, filter). - The hub opens a change stream for the source, captures a resume token, and takes a snapshot of matching documents (watch first, then snapshot — see Resume tokens).
- The client receives the snapshot, then live
PulseChangeevents as matching documents are inserted, updated, or deleted. - If the server restarts, the persisted resume token lets the provider resume from where it left off.
- If the client drops, the SDK reconnects and re-subscribes, treating the result as a fresh snapshot.
/src
Pulse.Abstractions/ IChangeSource, IFilterMatcher, ChangeEvent, SubscriptionFilter, ResumeToken, FilterExpr
Pulse.Server/ SignalR hub, subscription registry, match engine, hosting extensions
Pulse.Mongo/ MongoChangeSource : IChangeSource, Mongo resume-token codec
Pulse.Postgres/ PostgresChangeSource : IChangeSource (triggers + _changes log), PostgresFilterTranslator
Pulse.SqlServer/ SqlServerChangeSource : IChangeSource (change tracking), SqlServerFilterTranslator
Pulse.Client/ PulseClient SDK (ASP.NET, desktop, MAUI)
/tests
Pulse.Server.Tests/
Pulse.Mongo.Tests/ real Mongo via Testcontainers
Pulse.Postgres.Tests/ real Postgres via Testcontainers
Pulse.SqlServer.Tests/ real SQL Server via Testcontainers
Pulse.Client.Tests/
Pulse.Integration.Tests/ end-to-end: Testcontainers Mongo/Postgres/SQL Server + in-process SignalR + real PulseClient
/samples
Pulse.Sample.Server/ ASP.NET Core minimal API host wiring Pulse in
Pulse.Sample.MauiClient/ MAUI app (deferred until build step 10)
NuGet package IDs: Pulse.Abstractions, Pulse.Server, Pulse.Mongo, Pulse.Postgres, Pulse.SqlServer, Pulse.Client.
Subscriptions use a small, provider-neutral expression tree — not raw MongoDB query syntax — so it maps cleanly onto a BSON document (Mongo), a JSON document (Postgres), and a SQL row (SQL Server).
{
"source": "orders",
"where": {
"and": [
{ "field": "status", "op": "eq", "value": "pending" },
{ "field": "total", "op": "gte", "value": 100 }
]
}
}Operators: eq, ne, gt, gte, lt, lte, in, notIn, exists.
Semantics that matter (defined and tested explicitly):
- Field paths support dot notation for nested documents (
"customer.address.city"). - Comparisons perform explicit type coercion (a JSON
longvs adoublefield, etc.) — this is the #1 source of "filter silently doesn't match" bugs, so it is spec'd and covered by tests rather than left implicit. existsmeans key present and non-null.
FilterExpr has System.Text.Json converters in Pulse.Abstractions so server and client serialize/deserialize identically.
| Direction | Method / Event | Payload |
|---|---|---|
| Client → Server | Subscribe |
{ source, where } → returns subscriptionId |
| Client → Server | Unsubscribe |
subscriptionId |
| Server → Client | PulseSnapshot |
{ subscriptionId, documents: [...] } |
| Server → Client | PulseChange |
{ subscriptionId, kind, documentId, document? } |
| Server → Client | PulseError |
{ subscriptionId?, message } |
All wire DTOs live in Pulse.Abstractions so server and client share the exact same types — no hand-synced copies.
var builder = WebApplication.CreateBuilder(args);
// Mongo provider (change streams)
builder.Services.AddMongoSource(options =>
{
options.ConnectionString = mongoConnectionString;
options.Database = databaseName;
});
// Postgres provider (triggers + pulse._changes log)
builder.Services.AddPostgresSource(options =>
{
options.ConnectionString = postgresConnectionString;
});
// SQL Server provider (change tracking)
builder.Services.AddSqlServerSource(options =>
{
options.ConnectionString = sqlServerConnectionString;
});
var app = builder.Build();
app.MapHub<PulseHub>("/pulse");
app.Run();AddXxxSource() is provider-specific and registers its own SubscriptionRegistry, IResumeTokenStore (in-memory by default), and change source; nothing in Pulse.Server references a concrete provider. A server typically wires a single provider — multiple registries route by CanHandle, which defaults to claiming every source.
Shared per-source watchers are resilient to transient failures: if the underlying poller faults (a dropped connection, change-tracking cleanup, a missed NOTIFY), it restarts with capped exponential backoff and resumes from the last delivered resume point, so a momentary database blip doesn't permanently stop delivery. Private resumed watches surface the same fault through their Completion task.
Authorization is an extension point, not a system: implement IPulseAuthorizer and register it. See the caveat below — the default allows everything.
PulseClient works anywhere Microsoft.AspNetCore.SignalR.Client and the BCL run: ASP.NET server-to-server, WPF/desktop, and MAUI.
await using var client = new PulseClient("https://example.com/pulse");
await client.ConnectAsync();
var sub = await client.Subscribe<Order>("orders", new FieldCompare("status", CompareOp.Eq, "pending"));
sub.OnSnapshot += docs => /* initial matching documents */;
sub.OnChange += change => /* live diffs after the snapshot */;
IReadOnlyList<Order> current = sub.Current; // maintained local cache for direct UI bindingKey behaviors:
- The connection uses
WithAutomaticReconnect(). - After reconnect, the SDK re-subscribes and treats the result as a fresh snapshot (replaces
Current, firesOnSnapshotagain). This is not the same as the server-side gapless resume — see Resume tokens. - Final connection failure surfaces through
PulseClient.OnDisconnectedso the app can show a "reconnected failed" state. Currentis an optional convenience cache; raw events are always exposed for apps that manage their own state.
There are two distinct mechanisms in Pulse — do not conflate them:
-
Server-side resume tokens (
ResumeToken { ProviderId, Opaque }). Each watched source keeps a resume point, persisted to a pluggableIResumeTokenStore. On restart, watching resumes from that point so events are not silently dropped. If a token is stale/invalid (e.g. the Mongo oplog rolled off), the provider throwsResumeTokenInvalidExceptionand the caller decides to resync from a fresh snapshot — the token is never silently ignored. -
Client-side reconnect — the SDK's automatic resubscribe yields a fresh snapshot. The client cannot guarantee gapless delivery across a dropped connection, so it starts over. Callers should not expect a client reconnect to produce the same gapless behavior the server's resume token provides.
The snapshot-then-watch sequencing is deliberate: the provider opens the change stream first, captures its initial resume token as the snapshot's "as of" point, and then runs the snapshot query. Any change at or after that token supersedes the snapshot for a given document, so there is no gap or duplicate.
The Mongo provider emits accurate ChangeEvents with full post-change documents attached (FullDocument = UpdateLookup). The server registry then decides what each subscriber sees:
- Matched → no longer matches (update) → subscriber receives a synthetic remove (
ChangeKind.Delete, originalDocumentId) so live lists drop the row. - Didn't match → now matches (update) → subscriber receives it as an insert.
This logic lives in Pulse.Server's SubscriptionRegistry, not in the Mongo provider.
Pulse.Postgres implements the same IChangeSource contract on top of Postgres:
- Trigger + change log. Pulse installs a
pulseschema withpulse._changes(an append-only log) and a per-tableAFTER INSERT OR UPDATE OR DELETEtrigger that records each change as JSON and NOTIFies thepulse_changeschannel. Watchers LISTEN with a polling fallback, so delivery is immediate and resilient to a missed notification. _id= the primary key. Each row is delivered with its single-column primary key exposed as_id(column renamed). A table with no primary key or a composite primary key is rejected at subscribe time with an actionable error — Pulse cannot identify documents without a single_id.- Snapshot + resume tokens.
GetSnapshotAsynccaptures the change-log sequence number before the snapshot query (watch-first), so live watching picks up without a gap or duplicate. A resume token is the sequence number; a token that points past the current log is rejected as stale. - Change kinds. Postgres emits
insert,update, anddelete(noreplace). Updates carry the changed columns inupdated_fields, computed by diffing the new and old JSON rows.
Caveats specific to Postgres:
- Writes before the first subscription are not captured. The trigger is installed the first time a table is subscribed to; earlier writes are simply not in the change log (the subscribe-time snapshot covers them). The snapshot is always taken from the live table, so nothing is lost.
- Filter paths are JSON paths. Filters navigate
to_jsonb(row)with jsonb#>/#>>, so dotted paths (customer.address.city) and array indexes work, and comparisons preserve JSON types (a number field never equals a string filter value). Range comparisons require numeric or string filter values. - The change log no longer grows unbounded. While watchers are active, a background cleanup job (
PostgresSourceOptions.PruneInterval, default 1 minute) deletes rows below the minimum floor across active watchers for each source — the oldest shared-watch start point or resumed private-watch token, whichever is lower — so already-delivered history is reclaimed. A source with no active watcher is never pruned, so a resume token persisted across a restart stays valid even before any watcher reconnects.
Pulse.SqlServer implements the same IChangeSource contract on top of SQL Server Change Tracking:
- Native change tracking. On first subscribe, the provider runs
ALTER DATABASE SET CHANGE_TRACKING = ON(best-effort — it errors clearly if the login lacks permission) andALTER TABLE ... ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS_UPDATED = ON)per table. Watchers pollCHANGETABLE(CHANGES schema.table, @version)in 250 ms intervals; there is no trigger or change-log table of our own. _id= the primary key. Each row is delivered with its single-column primary key exposed as_id. A table with no primary key or a composite primary key is rejected at subscribe time with an actionable error. Sources are resolved asschema.table; a bare table name defaults todbo.ProviderIdForissqlserver:{schema}.{table}.- Snapshot + resume tokens.
GetSnapshotAsynccapturesCHANGE_TRACKING_CURRENT_VERSION()before the snapshot query (watch-first), so live watching picks up without a gap or duplicate. A resume token is an opaque 8-byte encoding of the change-tracking version; a token that points past the current version, belongs to a different provider, or has been cleaned up by retention (22119/22122) is rejected as stale viaResumeTokenInvalidException. - Change kinds. SQL Server emits
insert,update, anddelete(noreplace). Updates carry the changed columns inupdated_fields, decoded from the per-rowSYS_CHANGE_COLUMNSmask viaCHANGE_TRACKING_IS_COLUMN_IN_MASK. - JSON columns.
nvarchar/varcharcolumns that hold a valid JSON object or array are embedded as nested documents (mirroringjsonbon Postgres); plain text stays a string.
Caveats specific to SQL Server:
- Writes before the first subscription are not captured. Change tracking starts the moment the table is enabled; earlier writes are simply not in the change set (the subscribe-time snapshot covers them, taken from the live table).
- The primary key must be single-column and immutable. Change tracking records row keys, not full rows, so a row whose PK changes reads as a delete + insert. Decimal PKs are compared by exact byte value; other decimal fields still compare as doubles (a caveat shared with Postgres).
- Filter paths are JSON paths. Filters navigate the first segment as a real column and any remaining segments via
JSON_VALUEinside it, so dotted paths (customer.address.city) work on JSON columns, with numeric range comparisons viaTRY_CONVERT(decimal(38,18), ...)._idmaps to the primary key column. - No
LIMIT/BIGSERIAL/RETURNING. Snapshot and change queries useTOP-free ordered reads; tables need abigint identity(or similar) for numeric_idcolumns. updated_fieldsis unavailable on two-column tables. SQL Server reportsSYS_CHANGE_COLUMNS = NULLfor updates on tables with only a primary key and one other column, so Pulse cannot report which field changed there (the change event itself is still correct). Tables with three or more columns decodeupdated_fieldsnormally.
Read these before using Pulse in anything beyond a prototype.
- The default
IResumeTokenStoreis in-memory and does NOT survive a server restart. Persistence is opt-in:Pulse.Serverships a simple file-based store for v0.1; swap it in when you want restart durability. Without it, a restart can drop events. - The default
IPulseAuthorizer(AllowAllAuthorizer) allows any client to subscribe to anything. This is unsafe for production and must be replaced with your ownIPulseAuthorizerimplementation. - Unfiltered subscriptions (
where == null) are allowed but logged as a warning server-side — on a large collection they are a footgun. - Single-instance server. v0.1 does not scale horizontally. Running multiple server instances behind a load balancer is not supported (see Roadmap).
- Not a general message queue. Pulse is DB-state-change subscription only.
Prerequisites:
- .NET 8 SDK (pinned in
global.json) - Docker (Docker Desktop or any Docker daemon) — required for the Testcontainers-based Mongo, Postgres, and SQL Server tests
- A shell with
dotnetonPATH(Homebrewdotnet@8is keg-only:export PATH="/usr/local/opt/dotnet@8/bin:$PATH")
Commands:
dotnet build Pulse.sln # build everything
dotnet test Pulse.sln # run all test suites (spins up Mongo + Postgres + SQL Server via Testcontainers)
dotnet run --project samples/Pulse.Sample.Server # run the sample serverSolution-wide settings (target framework, nullability, lang version) live in Directory.Build.props; the SDK version is pinned in global.json.
Pulse.Abstractions—FilterExprJSON round-trip (serialize → deserialize → equal), including nestedAnd/Or/Not.Pulse.Server—DictionaryFilterMatcheragainst a table of documents/filters/expected results (all operators, nested paths); registry match-transition logic with a mockedIChangeSource.Pulse.Mongo— a real Mongo instance viaTestcontainers.MongoDb(change streams are not mocked): insert/update/delete detection, resume-after-token continuation, invalid-token exception path, snapshot-then-watch gap-free behavior.Pulse.Postgres— a real Postgres instance viaTestcontainers.PostgreSql(triggers and NOTIFY are not mocked): insert/update/delete detection with changed-field diffs, shared-watch fan-out, resume-after-token continuation, filtered snapshots (nested + arithmetic), composite/no-primary-key rejection, snapshot-then-watch gap-free behavior.Pulse.SqlServer— a real SQL Server instance viaTestcontainers.MsSql(change tracking is not mocked): insert/update/delete detection with changed-field masks, shared-watch fan-out, resume-after-token continuation, filtered snapshots (nested + arithmetic), composite/no-primary-key/missing-table rejection, stale-token rejection, snapshot-then-watch gap-free behavior.Pulse.Client— reconnect/resubscribe against an in-process test SignalR server.Pulse.Integration.Tests— end-to-end: real Mongo/Postgres/SQL Server + in-process SignalR hub + realPulseClient; subscribe, snapshot, mutate the database directly, assert the change arrives client-side; kill and restart the connection and assert resubscribe; restart the server with a file-backed resume token and assert no-gap resume.
The v0.1 implementation follows this order — each step is independently demoable:
| # | Step | Status |
|---|---|---|
| 0 | Scaffold solution, project graph, dependencies | Done |
| 1 | Pulse.Abstractions: types + JSON converters + round-trip tests |
Done |
| 2 | Pulse.Mongo: WatchAsync against Testcontainers Mongo |
Done |
| 3 | Pulse.Server: minimal PulseHub broadcast (no filter/snapshot) |
Done |
| 4 | DictionaryFilterMatcher + filtered fan-out |
Done |
| 5 | GetSnapshotAsync + PulseSnapshot on subscribe (gap-free) |
Done |
| 6 | Resume-token persistence (IResumeTokenStore, in-memory + file-based) |
Done |
| 7 | Pulse.Client basic Subscribe<T> |
Done |
| 8 | Client reconnect/resubscribe | Done |
| 9 | Match-transition logic | Done |
| 10 | Pulse.Sample.MauiClient (MAUI list bound to Current) |
Pending |
v0.2 — multi-provider:
| # | Step | Status |
|---|---|---|
| 1 | Pulse.Postgres provider: _changes log + per-table triggers, shared watches, LISTEN/poll pump, resume tokens |
Done |
| 2 | Postgres GetSnapshotAsync + PostgresFilterTranslator (jsonb WHERE), composite/no-PK rejection |
Done |
| 3 | AddPostgresSource DI extension |
Done |
| 4 | Postgres unit + end-to-end tests (Testcontainers, restart-resume) | Done |
| 5 | Pulse.SqlServer provider (change tracking) |
Done |
| 6 | Postgres change-log pruning (periodic cleanup below the minimum active watcher floor) | Done |
v0.3 — third provider:
| # | Step | Status |
|---|---|---|
| 1 | Pulse.SqlServer provider: change tracking bootstrap, CHANGETABLE(CHANGES ...) pump, shared watches, version resume tokens |
Done |
| 2 | SQL Server GetSnapshotAsync + SqlServerFilterTranslator (JSON_VALUE WHERE), composite/no-PK rejection |
Done |
| 3 | AddSqlServerSource DI extension |
Done |
| 4 | SQL Server unit + end-to-end tests (Testcontainers, restart-resume) | Done |
- Horizontal scale-out — multiple server instances sharing subscription state. SignalR already supports a Redis backplane for connection fan-out; filter state and match-transition tracking would need to be shared/sharded too. The registry is structured so this is a non-breaking addition.
- Offline queue / conflict resolution for mobile clients.
- Row-level authorization beyond the
IPulseAuthorizerseam. - Richer filter DSL — array-element matching, full-text search, etc. (v2).