The Relational Core
A subscriber draws a circle on the map — "alert me within 5 miles of this ridge" — and later a fire is reported a few miles away. Deciding whether that fire falls inside that circle is a geometry question, and geometry questions are exactly what a relational database with spatial types is good at. That's the job of the SQL layer: the structured, queryable, overlap-aware half of the platform — locations, RBAC, alerts, movement history, audit, and feature flags.
The one rule that shapes every SQL repository
The C# library never sends ad-hoc SQL. It calls stored procedures, through Dapper. Every repository method opens a fresh connection, names a stored procedure, passes parameters, and maps the result onto a model. That's the whole pattern:
using SqlConnection conn = SqlConnectionFactory.GetConnection();
var command = new CommandDefinition(
commandText: Constants.DBO_LOCATION_GET_BY_ID, // "dbo.LocationGetById"
commandType: CommandType.StoredProcedure,
parameters: new { Id = id },
cancellationToken: ct);
return await conn.QueryFirstOrDefaultAsync<Location>(command);
Two things make this safe and repeatable. The connection comes from ISqlConnectionFactory
(a singleton that hands out fresh SqlConnection objects — you using them, never cache
them), and every repository inherits SqlRepository for the shared helpers
(GetByIdAsync<T>, GetByQueryAsync<T>, and the table-valued-parameter builder). The schema
itself — 26 tables, 49 stored procedures, the user-defined types — lives in a separate
database project and ships as a DACPAC.
Each procedure is named once in its .sql file and again as a constant in
Utilities/Constants.cs (DBO_LOCATION_GET_BY_ID = "dbo.LocationGetById"). Dapper sends the
constant's string. If the two drift, you get a runtime "could not find stored procedure"
error, never a compile error. And enums are stored as strings — pass them as
.ToString() and register a DapperStringEnumTypeHandler<TEnum>, or the map comes back empty.
The interesting half: geometry
Most of the 19 repositories are thin proc calls you can read in a minute. The part that
isn't obvious is GeometryService — the NetTopologySuite/ProjNET layer that turns
map shapes into things SQL Server can compare. Two things create a location's shape, and it's
worth being clear which is which:
- Complex polygons come from the frontend. When a user draws an arbitrary area on the map,
it arrives as WKT or GeoJSON — any shape, however many vertices — and
GeometryServiceparses and validates it, then it's stored as-is. This is the general case, and the shape is whatever the user drew. - The circle is the built-in shape you get from a point and a radius. When all you have is
a center and a distance (an anomaly reported at a lat/lon, say), there's no polygon to store,
so
GetCircleFromPointgenerates one. It's a convenience for the point+radius case, not the only kind of area.
That generated circle hides a subtlety worth feeling for yourself. You can't just treat "5
miles" as a fixed number of degrees, because a degree of longitude is ~69 miles at the equator
but shrinks toward the poles. So the same physical radius covers more degrees the farther
north you go. GetCircleFromPoint corrects for this with the actual latitude before it
buffers. Run it across a few latitudes and watch the same 5 miles buy more degrees going north:
// Mirrors GeometryService.MilesToDegreesAccurate — verify against source if this looks stale. function milesToDegrees(miles, latitude) { const meters = miles * 1609.344; const earthRadiusMeters = 6378137.0; const latRad = latitude * (Math.PI / 180); // degreesPerMeter grows as cos(latitude) shrinks toward the poles const degreesPerMeter = 1 / (earthRadiusMeters * Math.cos(latRad) * (Math.PI / 180)); return meters * degreesPerMeter; } const miles = 5; const lats = [0, 34, 40, 49]; const lines = lats.map(function (lat) { return 'lat ' + lat + ': ' + milesToDegrees(miles, lat).toFixed(5) + ' deg for ' + miles + ' mi'; }); render(<pre style={{margin: 0}}>{lines.join('\n')}</pre>);
At the equator (0°) five miles is one number of degrees; at California's 34–42° it's
visibly larger, and the circle is built with 64 segments at that corrected radius. Get this
wrong and every proximity alert near the state's northern edge is subtly too small.
What lives in the SQL store
The repositories group into a few concerns, and the full method list for each is one click away in source — reach for it rather than a table here:
- Locations & alerts —
LocationRepository,AlertSubscriptionRepository,AlertMessageRepositoryand its three junction repositories (user / agency / location). - RBAC —
CameraSqlRepository(user↔camera, agency↔camera),UserAgencySqlRepository. - Camera activity —
CameraMovementRepository,CameraLeaseRepository,ActivityLogRepository. - Platform —
NotificationRepository,AuditEventRepository,FeatureFlagRepository,UserSqlRepository.
The Condor AI detection tables also live in SQL, but they're a domain of their own — event, COCO detections, and annotations read back as one graph — and get their own section: Condor AI Detections.
The COCO repositories don't insert detections one row at a time — they pass a
List<…DTO> as a single table-valued parameter (COCODetectionList, COCOAnnotationList)
built via the base class's BuildStringDataTable helper. Inserting in a loop instead works
in tests and then melts under a real detection payload. If you add a bulk path, add a UDT.
Wrapping up
The SQL layer is Dapper calling stored procedures — never ad-hoc SQL — with the schema shipped
as a DACPAC and the genuinely hard logic concentrated in GeometryService, where a physical
radius has to be converted to degrees against real latitude before SQL Server can answer an
overlap question. Keep the proc-name constants in sync, store enums as strings, and match the
misspelled namespace.
Next, the metadata store with a trick of its own — one write, two tables: The Dual-Table Index Trick.