Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
227 lines (197 loc) · 11.4 KB
/
Copy pathProgram.cs
File metadata and controls
227 lines (197 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
class Program
{
// Back the sample with EfLocalDb: each launch runs against a fresh LocalDB database, cloned from a
// seeded template. EfLocalDb manages its own instance and self-heals orphaned files, so the sample
// can never wedge on a leftover .mdf the way a fixed-name EnsureCreated database can.
static SqlInstance<SampleContext> sqlInstance = new(
constructInstance: _ => new(_.Options),
buildTemplate: _ =>
{
SampleContext.Initialize(_);
return Task.CompletedTask;
});
static async Task Main(string[] args)
{
// Named after this method, unless the launcher names it. The browser suite runs several of these
// servers at once, and EfLocalDb takes an existing database of the same name offline to replace
// it — under whichever server was already running on it.
var name = Environment.GetEnvironmentVariable("SAMPLE_DATABASE");
SqlDatabase<SampleContext> database;
if (name is null)
{
database = await sqlInstance.Build();
}
else
{
database = await sqlInstance.Build(name);
}
var builder = WebApplication.CreateBuilder(args);
// Serve the Blazor client's static web assets even when not running in the Development environment.
builder.WebHost.UseStaticWebAssets();
// The interceptor reports what this context saves, so the live queries reading it are asked
// again at once. Resolved rather than constructed: AddScry registers it, wired to the same
// place the server listens.
// begin-snippet: changeInterceptor
var services = builder.Services;
services
.AddDbContext<SampleContext>((services, options) => options
.UseSqlServer(database.ConnectionString)
.AddInterceptors(services.GetRequiredService<ScryChangeInterceptor>()));
// end-snippet
// The sample's own authorization data, and the policy that reads it. The policy is resolved
// from here rather than constructed, which is what lets it take a dependency at all.
services.AddSingleton<RegionGrants>();
services.AddSingleton<RegionAccessPolicy>();
// begin-snippet: sampleCommandHandlers
// The handlers for the model's commands — every one of them, since a server with commands on
// routes them all — and what they are tuned by, from the Sample:Commands section.
services.AddSampleCommandHandlers(builder.Configuration);
// end-snippet
// begin-snippet: serverRegistration
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;
// begin-snippet: addCachedPolicy
// 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);
// end-snippet
// 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}";
// begin-snippet: liveQueryRegistration
// 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>();
// end-snippet
// 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;
});
// end-snippet
// For MapScryHub below. Scry.Server.SignalR needs nothing registered beyond SignalR itself.
services.AddSignalR();
// For MapScryMcp below.
services.AddScryMcp();
// Scry's telemetry is dormant until something subscribes; opting in is one AddSource and one
// AddMeter. See /docs/observability.md for the spans, instruments, and tags.
// begin-snippet: openTelemetry
services.AddOpenTelemetry()
.WithTracing(_ => _.AddSource(ScryInstrumentation.ActivitySourceName))
.WithMetrics(_ => _.AddMeter(ScryInstrumentation.MeterName));
// end-snippet
var app = builder.Build();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
// begin-snippet: mapScry
app.MapScry("/api/query");
// end-snippet
// The same queries over a SignalR hub, beside the HTTP endpoints rather than instead of them.
// What the transport switch on the /live pages connects to: every live query a page holds
// then shares the one connection. Optional — see /docs/live-queries.md.
// begin-snippet: mapScryHubSample
app.MapScryHub("/api/query-hub");
// end-snippet
// Point an MCP client at http://localhost:<port>/mcp. A real host puts RequireAuthorization on
// what this returns; the sample has no sign-in.
// begin-snippet: mapScryMcpSample
app.MapScryMcp("/mcp");
// end-snippet
// What the /permissions page drives. None of it is Scry's — it is the sample standing in for
// the two things only a host can know about a cached policy.
app.MapGet("/api/grants", (RegionGrants grants) =>
new GrantState([.. RegionGrants.Regions], [.. grants.For("sample")], grants.Lookups));
// begin-snippet: invalidateCachedPolicy
// A grant moved. Nothing about any order changed, so no version column could notice and no
// query would ever decide those rows again — the cache has to be told, and telling it is part
// of the authorization path rather than a cache optimization.
app.MapPost(
"/api/grants/{region}",
(string region, bool allowed, RegionGrants grants, ScryPolicyCache cache) =>
{
grants.Set("sample", region, allowed);
cache.InvalidateScope<Order>("sample");
return Results.NoContent();
});
// end-snippet
// begin-snippet: cachedPolicyReadThrough
// A row changed. Nobody tells the cache anything here: the next query sees a revision past the
// watermark this scope was decided up to, and decides that one row on the spot. An insert by
// any writer at all is correct on its first read for the same reason.
app.MapPost(
"/api/orders/{id:int}/touch",
async (int id, SampleContext data) =>
{
var order = await data.Orders.FindAsync(id);
if (order is null)
{
return Results.NotFound();
}
// Named explicitly: Scry's async terminals and EF's are both in scope here, and they are
// not the same method — this one has to run against the database.
order.Revision = await EntityFrameworkQueryableExtensions.MaxAsync(data.Orders, _ => _.Revision) + 1;
await data.SaveChangesAsync();
return Results.NoContent();
});
// end-snippet
// What the /live pages drive besides the RepriceOrder command, which a client sends and the
// sample's handler saves through the context: a write the interceptor cannot see.
// begin-snippet: changesNotify
// A bulk update never passes through SaveChanges, so no interceptor can see it. The host
// says what it wrote instead. Without that line the change marker would still catch it a
// moment later, and the poll after that — but this is at once, and names the entity.
app.MapPost(
"/api/orders/reprice-bulk",
async (SampleContext data, ScryChanges changes) =>
{
var orders = data.Orders;
await orders.ExecuteUpdateAsync(_ => _.SetProperty(_ => _.Amount, _ => _.Amount + 1));
changes.Notify<Order>();
return Results.NoContent();
});
// end-snippet
// begin-snippet: mapExplorer
app.MapScryExplorer(_ =>
{
_.Route = "/scry";
// This sample always exposes the explorer. The default guard is Development-only — in a real
// app, run in Development or set EnableGuard to your own check (e.g. an admin authorization).
_.EnableGuard = _ => true;
});
// end-snippet
app.MapFallbackToFile("index.html");
await app.RunAsync();
}
}
/// <summary>What the /permissions page needs to render: the grants, and how much deciding they cost.</summary>
public record GrantState(IReadOnlyList<string> Regions, IReadOnlyList<string> Granted, int Lookups);