Skip to content
PapyrinePublic

About

A query framework to enable c# linq in Blazor Wasm apps backed by a EntityFramework backend.

Resources

Security policy

Stars

7 stars

Watchers

1 watching

Forks

Repository files navigation

Scry

Type-safe, serializable LINQ from a client to a server-side EF Core model.

When a UI evolves quickly, server-side querying usually forces a choice between hand-coding a bespoke endpoint and contract per use case, or adopting GraphQL/OData and shaping queries with a separate query language. Scry removes that trade-off while keeping everything in C# and strongly typed end to end:

  1. The EF Core model lives server-side. The client never references it — it is pointed at by path.
  2. A source generator in the client reads the model assembly directly by path (System.Reflection.Metadata), applies an allow-list, and generates strongly-typed client query DTOs plus a queryable entry point.
  3. The UI writes ordinary LINQ against the generated types.
  4. The LINQ is captured and serialized to a restricted query AST.
  5. The server deserializes, re-validates against the allow-list at runtime, rebinds to the real EF types, executes, and returns the projected rows.

Add or extend a query by writing LINQ in the client — no new endpoint, no new contract — while the server stays in full control of which types, properties, shapes, and rows can ever be returned.

Intended use

Scry is designed for a .NET client that runs outside the server's process — a Blazor WebAssembly front end, a WPF or Windows Forms desktop app, a console tool, or another service — talking to its own back end. The client has no EF dependency, so it stays small under a trimmed WebAssembly publish and light in an installed desktop app, while remaining strongly typed against the server's EF Core model. Client hosts covers what each host needs.

It also assumes the client and the back end are built by the same team and deployed together. A generated client is bound to the model surface it was generated against, and the two are expected to move in lockstep. Scry is deliberately not a general-purpose web API: it is not intended as a stable public contract for multiple external consumers, third-party apps, or clients on release cycles the team does not control. See docs/schema-versioning.md for how drift between the two is detected and mitigated.

"Same team" is about coupling, not trust. The client is still treated as hostile — the generated code, the LINQ, and the wire request are all attacker-controlled — and every guarantee is re-enforced server-side at runtime. See docs/security.md.

Compared to other approaches

Scry sits in a narrow slot beside hand-written endpoints, schema-language query APIs, URL query conventions, expression-tree serializers, and database-generated APIs. Comparisons places each by type and by name, and lists when Scry is the wrong choice.

How it works

The build-time and runtime flows are deliberately independent. Nothing is referenced across the client/server boundary: the only things that cross it are the model dll by path (build time) and the serialized wire AST (run time).

Build time — generating the client

The source generator reads the EF model assembly by path and emits strongly-typed client query types from the allow-listed surface only. The assembly is never referenced, loaded, or executed.

flowchart TB
    subgraph model["Server model"]
        EF["EF model<br/>+ Scry.Annotations<br/>([Queryable], [QueryIgnore], …)"]
        DLL["Model dll"]
        EF --> DLL
    end

    subgraph client["Client (no EF dependency)"]
        GEN["Source generator<br/>reads dll via<br/>System.Reflection.Metadata"]
        GENTYPES["Generated query types<br/>(Scry.Generated)"]
        GEN --> GENTYPES
    end

    DLL -. "by path, never referenced" .-> GEN
Loading

Run time — a query round-trip

The client's LINQ is captured (never executed client-side) and serialized to a restricted AST. The server re-validates that AST against the allow-list — to completion, before anything is respond — then rebinds to the real EF types, executes, and returns only the projected rows. A byte[] member marked [BinaryTransfer] skips base64 and travels as a raw multipart part beside the JSON (binary transfer); one marked [Attachment] is not carried at all, and is fetched on demand by row key through a check of its own (attachments).

flowchart TB
    subgraph client["Client"]
        LINQ["UI writes linq<br/>against generated types"]
        CAPTURE["QueryProvider<br/>captures expression tree<br/>(never executed here)"]
        TRANS["QueryTranslator<br/>→ restricted query AST"]
        LINQ --> CAPTURE --> TRANS
    end

    subgraph wire["Scry.Wire"]
        REQ["QueryRequest AST<br/>(closed operator + node set)"]
    end

    subgraph server["Server"]
        SCHEMA["Schema.Build<br/>allow-list from the real model"]
        VALID["QueryValidator<br/>authoritative gate<br/>(runs to completion first)"]
        BUILD["ExpressionBuilder<br/>rebind to real EF types"]
        EXEC["QueryExecutor + ProjectionPlan<br/>execute + shape rows"]
        DB[("EF → DB")]
        RESP["QueryResponse"]
        SCHEMA -. "allow-list" .-> VALID
        VALID --> BUILD --> EXEC --> DB
        DB -- "projected rows" --> RESP
    end

    TRANS -- "serialize + send" --> REQ
    REQ -- "deserialize" --> VALID
    RESP -- "rows" --> LINQ
Loading

See docs/security.md for the full threat model.

Writes — commands

Queries never write. Writes are commands: a class in the model marked [Command], generated into the client beside the query models and sent through Query.Commands. The server binds the payload into its own class, decides it with the command's policy — row by row, for a command that targets a row — and hands it to an in-process handler or a message bus. A command decided within a second answers at once; one that takes longer answers Pending and streams its outcome on the same response. What it wrote reaches every screen through the live queries that read it.

Packages

Package Purpose
Scry.Annotations Allow-list attributes applied to the server model.
Scry.Wire The serializable query AST shared by client and server.
Scry.Client Client-side IQueryable provider (no EF dependency). Ships the source generator.
Scry.Server Server-side validation + execution against EF Core.
Scry.Server.Explorer Opt-in, GraphiQL-style query explorer.
Scry.Server.Delta Opt-in 304 Not Modified, and a change probe for live queries, backed by Delta.
Scry.Server.SignalR Opt-in: the query surface over a SignalR hub, so many live queries share one connection.
Scry.Client.SignalR Opt-in: a ScryClient over a SignalR hub connection.
Scry.Server.Mcp Opt-in: the query surface, and optionally commands, served to AI agents over MCP.
Scry.Server.Redis Opt-in: carries live-query change notifications between server nodes over Redis pub/sub.
Scry.Server.MessagePipe Opt-in: the same over MessagePipe's distributed pub/sub.
Scry.Server.NServiceBus Opt-in: the same over NServiceBus, including what a worker endpoint's handlers save — and commands carried to a worker and answered when it replies.
Scry.Server.MassTransit Opt-in: commands carried over MassTransit.
Scry.Server.Rebus Opt-in: commands carried over Rebus.
Scry.Server.Wolverine Opt-in: commands carried over Wolverine.

Scry.SourceGenerator is packed inside Scry.Client rather than published separately.

Every package puts its public types in the single Scry namespace, so one using Scry; covers all of them. The generated query models are the exception — they land in Scry.Generated.

At a glance

Annotate the server model:

[Queryable]
public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public Status Status { get; set; }
    public bool Active { get; set; }
    public DateOnly Created { get; set; }

    public int? ManagerId { get; set; }
    public Employee? Manager { get; set; }

    public int DepartmentId { get; set; }
    public Department? Department { get; set; }

    // A claim check rather than a value: no query reads it, and what a client gets back is a handle
    // carrying this row's key. A photo is the case the attribute exists for — bytes nothing wants on
    // every row of every query, fetched by the one thing that actually wants to draw them. The check
    // that authorizes the fetch is registered by the server; this project references the annotations
    // alone, so [AttachmentWith] has no policy type to name here.
    [Attachment(ContentType = "image/svg+xml")]
    public byte[]? Photo { get; set; }

    // Never exposed to clients.
    [QueryIgnore]
    public decimal Salary { get; set; }

    // The other half of that pair: queryable, but never in a URL and never in a cache. [QueryIgnore]
    // hides a member outright; [Sensitive] keeps it askable while refusing the two ways its value
    // escapes — a query comparing it against a constant travels as a body rather than a URL, where the
    // constant would land in every access log on the way, and a response projecting it is sent
    // no-store, where a cacheable one would be written to the caller's disk.
    [Sensitive]
    public string Password { get; set; } = "";
}

snippet source | anchor

Register and map on the server:

services
    .AddScry<SampleContext>(_ =>
    {
        // Holiday is a [QueryablePoco]: it has no table, so the server supplies its rows. Every
        // [QueryablePoco] type must be registered here or AddScry throws at startup.
        _.AddPocoSource(_ => Holiday.Seed());
        // Department.Handbook and Employee.Photo are [Attachment]s, and one exposed without a
        // check is a startup failure. Registered here rather than by [AttachmentWith] because
        // the model project references the annotations alone and has no server type to name.
        _.AddAttachmentPolicy<Department, HandbookPolicy>();
        _.AddAttachmentPolicy<Employee, PhotoPolicy>();
        _.MaxPageSize = 200;

        // A row policy whose decision is too slow to run per row in SQL, so it runs in C# and
        // the server remembers what it answered. Revision is what tells it a row has changed
        // and needs deciding again — see /docs/policies.md and the /permissions page.
        _.AddCachedPolicy<Order, long, RegionAccessPolicy>(_ => _.Revision);

        // Repeat a query while nothing has been written and the answer is a 304 rather than a
        // re-execution. Optional, and off until a freshness source says how to tell — see
        // /docs/caching.md.
        _.UseDeltaFreshness<SampleContext>();

        // What a cached response belongs to. This server has sources whose answers depend on
        // who asked — the row policy above, and Department.Handbook's attachment check — and
        // MapScry refuses to start without this. The sample has no sign-in, so the caller
        // half is a constant; a real app returns its tenant or its principal, and a client
        // signing in as someone else is then never handed the previous one's rows.
        //
        // The grants version is the other half, and is the part worth copying. A response
        // varies by what the caller is allowed to see, and QueryFreshness only watches the
        // database — so a grant changing outside it would move nothing, and a cache holding
        // the old rows would go on answering with rows the caller has since lost.
        _.CacheScope = _ => $"sample-{_.RequestServices.GetRequiredService<RegionGrants>().Version}";

        // Live queries: the /live pages. Off until a server says how many it will hold open,
        // which is also what maps the route — see /docs/live-queries.md.
        _.MaxSubscriptions = 100;

        // The interceptor above reports this server's own saves, at once and by entity. This
        // watches the database's change marker for everything it cannot see: a bulk update,
        // another node, a script run by hand.
        _.UseDeltaChanges<SampleContext>();

        // Commands: the /commands page and the /live pages' Reprice. Off until a server says
        // how many it will have in flight, which is also what maps the routes — see
        // /docs/commands.md.
        _.UseSampleCommands();

        // An AI agent's way in: the schema, queries and commands over MCP, at /mcp. Off
        // until a server says what an agent may do — see /docs/mcp.md.
        _.Mcp = ScryMcpAccess.ReadWrite;
    });

snippet source | anchor

AddPocoSource supplies the rows for a [QueryablePoco] type — see POCO sources.

app.MapScry("/api/query");

snippet source | anchor

Point the client at the model by path — no reference:

<!-- The server model, pointed at by path. NOT referenced. -->
<ScryModelDll>$(MSBuildThisFileDirectory)..\Sample.Model\bin\$(Configuration)\net10.0\Sample.Model.dll</ScryModelDll>

snippet source | anchor

Then write LINQ:

employees = await Query
    .Employee
    .Where(_ => _.Active)
    .OrderBy(_ => _.Name)
    .Select(_ => new EmployeeRow(_.Name, _.Status, _.Manager!.Name, _.Department!.Name))
    .ToListAsync();

snippet source | anchor

Then send a command, and read what came of it:

static async Task<int> Reprice(ScryQuery query, int id)
{
    var outcome = await query.Commands.RepriceOrder(new() {Id = id});
    if (outcome.Status == ScryCommandStatus.Pending)
    {
        Console.WriteLine($"Order {id} is being repriced…");
        outcome = await outcome.Completion;
    }

    switch (outcome.Status)
    {
        case ScryCommandStatus.Completed:
            Console.WriteLine($"Order {id} repriced.");
            return 0;
        case ScryCommandStatus.Failed:
            await Console.Error.WriteLineAsync($"Order {id} was not repriced: {outcome.Error}");
            return 1;
        default:
            await Console.Error.WriteLineAsync($"Whether order {id} was repriced is unknown: {outcome.Error}");
            return 1;
    }
}

snippet source | anchor

Live queries

Any query can be kept answered. Live() in place of ToListAsync() — or LiveCount(), LiveAny(), LiveFirstOrDefault() — is answered now, and again whenever its answer changes:

protected override void Start()
{
    // The LINQ is what it would be for ToListAsync. Live() in its place means the answer keeps
    // arriving: now, and again whenever the rows it reads change.
    rows = Query
        .Order
        .OrderBy(_ => _.Id)
        .Select(_ => new OrderRow(_.Id, _.Region, _.Amount))
        .Live()
        .Subscribe(
            answer =>
            {
                orders = answer;
                InvokeAsync(StateHasChanged);
            },
            Failed);

    // Any terminal can be live. This one is a second subscription of its own, and the server
    // sends it a number rather than the rows.
    counting = Query
        .Order
        .LiveCount()
        .Subscribe(
            answer =>
            {
                count = answer;
                InvokeAsync(StateHasChanged);
            },
            Failed);
}

// A subscription outlives nothing: leaving the page ends it, and the server is told.
protected override async ValueTask Stop()
{
    if (rows is not null)
    {
        await rows.DisposeAsync();
    }

    if (counting is not null)
    {
        await counting.DisposeAsync();
    }
}

snippet source | anchor

It is consumed as a stream (await foreach), with a callback, or as a plain IObservable for Rx, with no reactive library referenced by Scry. Every answer is the query run again through the allow-list and the row policies, sent only where it differs from the one before, so a change the caller may not see produces no answer. It is off until a server sets MaxSubscriptions.

The sample's live page: a table of orders and a live count, with buttons that write to the server and a switch between the SSE and SignalR transports

What tells one to run again is an EF SaveChanges interceptor, the host, the database's own change marker, and a poll beneath them all. Opt-in packages carry changes between server nodes over Redis, MessagePipe or NServiceBus, and serve the whole query surface over a SignalR hub. See Live queries.

Commands

A write is a class the model marks [Command]. The client sends it through the generated Query.Commands, and the server binds it into its own class, decides it with the command's policy and hands it to a handler:

/// <summary>Deletes one employee — an inactive one, by the sample's policy.</summary>
[Command(typeof(Employee))]
public class DeleteEmployee
{
    public int Id { get; set; }
}

/// <summary>Renames one employee. A name containing "slow" takes a while, to show a command going pending.</summary>
[Command(typeof(Employee))]
public class RenameEmployee
{
    public int Id { get; set; }

    [StringLength(100, MinimumLength = 1)]
    public string Name { get; set; } = "";
}

/// <summary>Deactivates or reactivates one employee: what makes a row deletable, and deletable again.</summary>
[Command(typeof(Employee))]
public class SetEmployeeActive
{
    public int Id { get; set; }
    public bool Active { get; set; }
}

/// <summary>Hires an employee, answering with the new row's id.</summary>
[Command(Result = typeof(EmployeeCreated))]
public class CreateEmployee
{
    [StringLength(100, MinimumLength = 1)]
    public string Name { get; set; } = "";

    public int DepartmentId { get; set; }
    public Status Status { get; set; }
}

public class EmployeeCreated
{
    public int Id { get; set; }
}

snippet source | anchor

A targeted command adds a Can{Command} member to its target's query model, computed from the command's policy in the database, so a screen enables a row's button from the row itself — and inside a live query that is decided again on every answer. A command that takes longer than the server's sync window is answered Pending, followed to its end on the same response, and listed in a pending-work panel. What it wrote reaches the screen through the live queries that read it. Commands are off until a server sets MaxPendingCommands, and can be carried to a worker over NServiceBus, MassTransit, Rebus or Wolverine. See Commands.

The sample's commands page: a live table of employees, each row with Deactivate, Rename and Delete buttons, only the inactive row's Delete enabled, and a form to hire

Query explorer

An opt-in, GraphiQL-style explorer ships in Scry.Server.Explorer. It runs Roslyn in the browser, giving real IntelliSense and diagnostics against the allow-listed schema, and shows exactly what goes on the wire:

app.MapScryExplorer("/scry");

The Scry explorer: the schema pane, the LINQ, the wire request it translated to, and the rows the server returned

It is off unless mapped, and Development-only by default. A query builder beside the editor shows the query as rows — columns, filters, sorts, paging — and writes back whatever is changed there, for building a query without typing one. See Query explorer.

A Blazor client has a companion: a debug sidecar that opens over the running app (Alt+Q) and shows every Scry exchange the page has made — decoded requests, pretty-printed responses, headers, and a one-click jump into the explorer with the captured query pre-populated.

The sidecar open over the sample app: the captured exchanges, queries and attachment fetches alike, and one query's decoded request, response, and headers

Documentation

Icon

Ripple by Zach Bogart via The Noun Project

About

A query framework to enable c# linq in Blazor Wasm apps backed by a EntityFramework backend.

Resources

Security policy

Stars

7 stars

Watchers

1 watching

Forks

Contributors

Languages