Changelog v3.14.0 (2026-05-09)
Version 3.14.0 (2026-05-09)
This release sharpens the philosophy of the standalone client: REST endpoints come from PostgreSQL routines (functions, procedures) and from explicit SQL files — not auto-generated CRUD on tables and views. It also makes real-time push (SSE) more honest about what your annotations mean, and gets more throughput out of array- and composite-heavy responses.
Removed: auto-CRUD endpoint generation from the standalone client
The NpgsqlRest.CrudSource plugin is no longer wired into the NpgsqlRestClient standalone executable. The plugin itself still ships as a NuGet package and remains fully functional for embedded use — projects that consume the NpgsqlRest library directly can continue to register a CrudSource instance against EndpointSources exactly as before. Only the NpgsqlRestClient JSON-driven path is affected.
Why: auto-generated CRUD endpoints over arbitrary tables and views have always been a different shape from the rest of the project. The core promise is "your PostgreSQL routines become REST endpoints" — explicit, version-controlled, comment-annotated procedures and functions where the developer chose what to expose. Auto-CRUD inverts that: every table becomes ten URL patterns by default (select / insert / update / delete plus the various on conflict and returning variants), with no per-endpoint guardrails unless you opt back into them. That's a different product, and it doesn't belong in the same configuration surface.
What this means in practice:
appsettings.json: the entireNpgsqlRest:CrudSourceblock has been removed from the default template. Any existing configuration with that block will fail key validation at startup (the sameConfig:ValidateConfigKeyscheck that catches typos) — remove the block to upgrade.--configoutput:CrudSourceno longer appears in the dumped configuration.--versionoutput:NpgsqlRest.CrudSourceis no longer listed in either the human-readable or--jsonform, since the standalone client no longer references the assembly.- Library use: zero change.
using NpgsqlRest.CrudSource; sources.Add(new CrudSource())works exactly as it did in 3.13.0 and remains supported.
If you had "CrudSource": { "Enabled": true } in your config and depend on the generated endpoints, the migration is one of:
- Switch to function-based wrappers — the recommended path. Write the CRUD shape you actually need as PostgreSQL functions or procedures with
HTTPannotations. You get the same endpoints with explicit per-endpoint auth, validation, caching, rate limiting, and comment-driven URL shapes. - Embed the library in a custom host and register
CrudSourceprogrammatically. The plugin code still ships underplugins/NpgsqlRest.CrudSource/.
What's new
Two new SSE annotations: @sse_publish and @sse_subscribe
If you've ever had a manager-side procedure broadcast notifications to user-side subscribers, you've probably hit the awkwardness of the all-in-one @sse annotation: every emitter procedure ended up with a phantom /info URL nobody connected to, and your generated TypeScript client included createXEventSource() helpers for procedures that should never be subscribed to.
3.14.0 splits the responsibilities:
| Annotation | What it does |
|---|---|
@sse_publish | This procedure's RAISE statements feed SSE subscribers. No subscribe URL is exposed. |
@sse_subscribe | Exposes a subscribe URL for EventSource clients. The procedure body is never executed when a client opens the stream. |
@sse [path] | Same as before — shorthand for both. Unchanged. |
So a "manager broadcasts to users" flow now reads cleanly:
sql
-- subscriber URL, body never runs on subscribe
comment on function subscribe_user_events() is '
HTTP GET
@sse_subscribe
';
-- emitter, no subscribe URL, no useless TS EventSource helper
comment on procedure broadcast_user_message(...) is '
HTTP POST
@sse_publish
@sse_scope authorize
';The TypeScript client generator follows automatically: @sse_publish produces a plain POST function, @sse_subscribe keeps the EventSource helper, and @sse is unchanged.
All existing event filtering — @sse_scope, @sse_events_level, RAISE ... USING HINT, and the X-NpgsqlRest-ID execution-id header — works the same with both new annotations.
Warning when a RAISE looks like a missed @sse_publish
Forgetting @sse_publish on an emitter procedure used to fail silently: the RAISE ran, the notice logged, and zero events reached subscribers. NpgsqlRest now warns once per endpoint when a RAISE whose severity matches the configured SSE forwarding level fires in a procedure that has no @sse or @sse_publish:
code
WARN: RAISE INFO in endpoint /api/update-user-roles was not broadcast to SSE subscribers —
the endpoint has no @sse or @sse_publish annotation. Add @sse_publish to forward this
routine's notices, or set WarnUnboundServerSentEventsNotices=false to silence this warning.The warning only fires when the project actually uses SSE somewhere and only when the severity matches — projects that use RAISE NOTICE for unrelated logging see no warnings, and projects that don't use SSE at all see no warnings. Configurable via the new WarnUnboundServerSentEventsNotices setting (default true).
Reliable SSE connection handshake
SSE responses now flush a small "connected" line as soon as the broadcaster has registered the new subscriber, instead of waiting for the first real event. Browsers and EventSource clients ignore comment-only lines per spec, so no consumer behavior changes — but a client that wants to do "subscribe, then publish, then receive" can now rely on the handshake completing before its publish call. Mostly visible to integration tests; in production it makes connection states more predictable.
Startup error when claim-mapped parameters use a non-text type
If Auth.UseUserParameters is on and your ParameterNameClaimsMapping references a procedure parameter whose SQL type isn't text-compatible — for example _company_id int mapped to a company_id claim — every authenticated request used to crash with this error from deep inside the driver:
code
System.InvalidCastException: Writing values of 'System.String' is not supported
for parameters having NpgsqlDbType 'Integer'.The exception didn't mention claim mapping, so debugging meant a stack-trace hunt. NpgsqlRest now catches the misdeclaration at startup with a precise message naming the endpoint, parameter, claim, and the SQL type:
code
Endpoint POST /api/create-local-user parameter _company_id is mapped to claim
'company_id' but its type is 'int' which is not text-compatible. Claim values
are strings, so binding would fail at runtime with InvalidCastException.
Declare the parameter as text/varchar/char/json/jsonb/xml/jsonpath, or remove
'_company_id' from ParameterNameClaimsMapping.Accepted types: text, varchar, char, name, xml, json, jsonb, jsonpath, plus unknown (the SQL-file-source case where the driver resolves the type server-side). Any other type fails fast at UseNpgsqlRest. The check only runs for endpoints with UseUserParameters enabled and only for parameters that match a configured claim mapping.
Warning when a request value is overridden by claim auto-bind
When a parameter is auto-bound from a claim, the claim wins — that's intentional, especially for security-sensitive procedures where the caller's identity must override anything the request supplies. But if the request also sent a value for that parameter, the value used to be discarded silently. With certain UI patterns (forms that POST every field) this hid real bugs: in one case update_user_roles(_user_id text, _roles text[]) looked like it was updating a target user, but every request modified the caller's own roles because _user_id was claim-mapped.
The auto-bind still wins (no behavior change for security), but a collision now emits a warning so the developer can see what happened:
code
Endpoint /api/update-user-roles parameter _user_id received a body value but
is auto-bound from claim 'name_identifier'. The supplied value is being ignored.The warning fires only when the request actually supplied a value, naming the endpoint, parameter, source (body or query), and the claim.
Performance
A focused pass on response rendering. No public API or configuration changes.
Lower-allocation JSON conversion for arrays and composites
The four PostgreSQL → JSON conversion routines used to render array, composite, and tuple values now rent their working StringBuilder buffers from the existing pool instead of allocating fresh ones on every call. This affects PgArrayToJsonArray, PgCompositeArrayToJsonArray, PgTupleToJsonObject, and PgUnknownToJsonArray — all of which fire per row × per column on responses that include array or composite types.
Measured on a focused micro-benchmark (Apple M4 Pro, .NET 10, BenchmarkDotNet ShortRun, three iterations):
| Function | Before alloc | After alloc | Δ |
|---|---|---|---|
PgArrayToJsonArray (numeric, 100 elem) | 1.91 KB | 1.16 KB | −39% |
PgArrayToJsonArray (text, 100 elem) | 19.68 KB | 13.86 KB | −29% |
PgCompositeArrayToJsonArray (50 elem) | 23.02 KB | 11.52 KB | −50% |
PgTupleToJsonObject (10 fields) | 1.10 KB | 1.27 KB | within noise |
CPU time per call moved by single-digit percent — within or near the noise band of a short BDN run. The headline win is reduced GC pressure during sustained load: a 100-row response containing several array columns can drop ~1–2 MB of allocation per request, and a multi-row composite-array response can drop ~5–10 MB.
Estimated impact on the PostgreSQL REST API Benchmark 2026 workloads
These are extrapolations from the micro-benchmark above, not re-measured numbers from re-running the published benchmark. They estimate how the allocation reduction translates to throughput and tail latency under that benchmark's 100 VU sustained concurrency — where reduced GC pressure compounds. Scenarios with little or no array/composite work see essentially no change because the optimized paths don't fire.
| Scenario | Baseline (3.4.7) | What fires | Est. req/s Δ | Est. P99 latency Δ |
|---|---|---|---|---|
| Minimal Baseline | 16,065 req/s | Nothing — no arrays, no composites | 0% | 0% |
| Many Parameters (20) | 11,504 req/s | Query-string parsing only — not touched | 0% | 0% |
| POST Body (10 rec) | 6,101 req/s | Arrays in 10-row response | +1 to +3% | −2 to −5% |
| Data Type (1 rec) | 4,588 req/s | Few array columns × 1 row | +0 to +2% | −2 to −5% |
| Nested JSON (depth 1, 100 rows) | 3,061 req/s | Composite paths fire heavily | +5 to +10% | −5 to −15% |
| Large Payload (100 KB) | 1,096 req/s | Depends on payload shape | +0 to +3% | varies |
| Data Type (100 rec) | 377 req/s | Array cols × 100 rows — ~1–2 MB/req cut | +3 to +7% | −5 to −12% |
| Data Type (500 rec) | 82 req/s | Array cols × 500 rows — ~5–10 MB/req cut | +5 to +10% | −8 to −15% |
The largest absolute wins land on the high-record-count scenarios where allocation pressure is greatest. The largest relative tail-latency improvements land on the same scenarios because Gen0 stalls dominate P99 under that load shape.
Two important caveats:
- The published benchmark measured 3.4.7. Current
masteralready has months of perf work on top of that. These estimates apply on top of current state; they assume the relative shape (CPU vs. PostgreSQL vs. network) hasn't shifted dramatically since 3.4.7. - End-to-end requests spend most of their time in PostgreSQL, network round-trip, and Kestrel. The optimized paths are a slice of response rendering, so the gains compound only where rendering CPU or GC is the bottleneck. For a single-row response the optimized work is microseconds out of milliseconds; for a 500-row array-heavy response it's a much larger share.
UTF-8 literals for JSON markup constants
Consts.Utf8OpenBrace, Utf8CloseBrace, Utf8OpenBracket, Utf8CloseBracket, Utf8Comma, Utf8Colon, and Utf8Null are now static ReadOnlySpan<byte> properties backed by "x"u8 UTF-8 string literals, instead of static readonly byte[] fields. The bytes are embedded directly in the assembly metadata, so each access is a pointer-and-length to read-only data — zero heap allocation, ever. Eliminates seven small startup-time allocations.
Tighter PipeWriter writes
The hot-path JSON markup writes (commas, braces, brackets, the "null" literal) have been collapsed from a three-step GetSpan / CopyTo / Advance pattern to a single IBufferWriter<byte>.Write(ReadOnlySpan<byte>) call across ten call sites in the response renderer. Same allocation profile, fewer chances to mismatch sizes.
Hardening (silent-failure fixes)
Each of these fixes a class of silent failure that used to require log digging or memory monitoring to detect.
ArrayPool rent now in try/finally
PgCompositeArrayToJsonArray rents a char[] from ArrayPool<char>.Shared for inputs over 512 chars and previously returned it only on the success path. If the parsing loop threw, the rented buffer was lost from the shared pool until process exit — a slow, silent leak that compounded over uptime. Returns now happen in finally, so a malformed PostgreSQL value can't poison the pool.
Multi-command StringBuilder rentals always released
The mcRowBuilder and mcCompositeBuffer StringBuilders rented inside the multi-command result-rendering loop are now lifted to method scope and released in an outer finally even if the inner reader loop throws.
proxy_out buffer released on exception path
The MemoryStream used by the proxy_out feature to capture function output before forwarding upstream is now disposed in an inner try/finally, so a forwarding failure can't leak the buffer.
Column-decryption failures now logged at Trace
Three call sites that decrypt column values via IDataProtector.Unprotect previously had silent catch { } blocks — by design, so a failed decryption falls back to the raw ciphertext rather than surfacing as a 500. The fall-back is preserved, but the failure is now logged at LogLevel.Trace:
code
Column decryption failed; falling back to raw value. Error: <message>A misconfigured key, a tampered ciphertext, or a key-rotation mismatch is now observable when Trace logging is enabled instead of being completely silent.
Configuration
One new optional setting:
WarnUnboundServerSentEventsNotices(defaulttrue) — controls the new "missed@sse_publish" warning described above. Setfalseif your project intentionally usesRAISEfor non-SSE logging and you don't want NpgsqlRest commenting on it.
No keys removed or renamed; existing appsettings.json works as-is.
Test suite
1949 tests pass on the release branch. 12 of those are new for the SSE work (URL routing under each annotation combination, end-to-end live event delivery from a publisher procedure to a subscriber on a different procedure's URL, the missed-annotation warning, TS client output for both new annotations) and 5 are new for the claim auto-bind diagnostics. A new SseTestClient helper opens streaming HTTP connections and waits for the broadcaster to register subscribers before publishing — reusable for upcoming SSE work like heartbeats and Last-Event-ID resume.