MorphDB 0.12.0
A minor on the 0.11.x line — the first release cut from main since 0.11.1 shipped as a
hotfix, so main's declared version catches up with what is published. Four things break, all on
the real-time and export surfaces, and each removes a member that no code path ever honoured: a
webhook delivery is camelCase like every other response, an export request has no filter or
orderBy, the Subscribe hub method takes one argument, and the .NET client's
ChangeNotification drops the never-set OldData. The larger share of the release is fixes to
the real-time path — a row wider than a NOTIFY payload, out-of-order broadcast, the .NET client's
subscription that never fired — and to the reference, which now describes the surface the server
actually has. What a 0.11.x client can still do against this server is in docs/COMPATIBILITY.md.
Breaking
- A webhook delivery's fields are camelCase. The delivery payload was the one surface in this
API that serializedsnake_case; a receiver readrecord_idwhere every other response names
recordId. The naming was explained by the receiver being a third-party endpoint rather than a
client of this API, which is true and still does not make one API worth two conventions. The
payload now serializes with the same camelCase policy as the rest of the surface —record_id
becomesrecordId, and the other four field names are unchanged because they are single words.
Wire change: a receiver that readsrecord_idmust readrecordId. It lands in the same
release as thepreviousremoval below, so a receiver adjusts to the payload once rather than
twice. - An export request has no
filterororderBy. The three export request bodies (CSV, JSON,
XLSX), the .NET client's option types and the core option records declared both members, and no
export ever applied either — an export was always the whole table in storage order, so a caller
who sent a filter received an unfiltered file and no signal. The members are gone; a body that
still names one is refused at the request as an unknown member (400, listing the supported
members), which is the same answer every other request body already gives. An export is the
whole table; there is no export-shaped replacement for a subset. To work with a subset, page it
throughGET /api/data/{table}?filter=...and assemble the file yourself — that route does
filter, and always has (Verified-by: DataApiTests.Query_WithFilter_ShouldReturnFilteredResults). - The
Subscribehub method takes one argument. It declared a second, optional
SubscriptionOptions— a filter, a field list and an include-data flag — that every subscribe
call stored and no broadcast ever read. SignalR binds an invocation by argument count, and no
wire protocol carries a C# default, so the only effect the parameter ever had was to make the
one-argument call every client SDK and this project's own documentation describe fail at the
binder. The parameter and the options type are gone from the hub and from the .NET, TypeScript
and Python clients;subscribethere now takes a table name and a callback. A subscription was
already per table and nothing narrower — no filtering behaviour is lost, because none existed. ChangeNotificationin the .NET client losesOldData, and itsRecordIdis nullable.OldData
was documented as the row before an update and was never set — no event carries a before-image —
so a consumer reading it always sawnull; it is gone rather than kept as a field that lies.
RecordIdisGuid?, as it is on the event, instead of aGuidthat readGuid.Emptywhenever
no id was carried. Code that referencesOldData, or assignsRecordIdto a non-nullableGuid,
stops compiling until it is adjusted; nothing that compiled ever observed a different value, since
the client's real-time callback had never been invoked (see Fixed).
Added
RealtimeClient.SubscribeAsyncaccepts a task-returning callback. A second overload takes
Func<ChangeNotification, Task>alongside the existingAction<ChangeNotification>, the same
pair SignalR's ownHubConnection.Onoffers. Anasynclambda now binds to it, and the client
awaits each delivery before dispatching the next — so a callback that writes each change
somewhere can await that write and rely on the order, and an exception it throws is observed
rather than lost. TheMorphDB.ClientREADME's real-time example, which passed anasync
lambda to theActionoverload and so compiled asasync void, now shows both forms
(Verified-by: RealtimeClientTests.An_async_callback_is_awaited_before_the_next_change_is_delivered).
Deprecated
- The Python and TypeScript clients under
sdk/are archived. They were reference
implementations that no workflow ran; measured against the server they document, 4 of their 11
documented methods worked. Rather than carry a second and third client contract that nothing
verifies, the source stays in the repository at the0.11.xcontract, marked archived in each
README, and is not maintained, tested, or published — except for this release's ownSubscribe
signature change (above), applied here because leaving it would have made the one call both
clients document fail at the binder. Neither client is updated for any other contract change.
The supported clients are the API itself andMorphDB.Client(.NET) —docs/COMPATIBILITY.md
anddocs/TESTING.mdsay so.
Removed
- The GraphQL subscription root —
onRecordCreated,onRecordUpdated,onRecordDeletedand
onRecordChanged. The schema served them,docs/API.mddocumented them with examples, and a
sender for them was registered — and nothing ever called it: no write, through any door, published
a single event, so a documented subscription connected and then received nothing, with no error
to explain it. The fields, the type, the in-memory subscription provider and the sender are gone,
and with them the last real-time surface that was not the SignalR hub. Nothing that worked
changes: no event was ever delivered through them
(Verified-by: GraphQlSchemaContractTests.The_operations_clients_call_are_all_present). - The real-time hub's
OnErrorclient event.IMorphHubClientdeclared it anddocs/API.md
documented it with acode/messagepayload, and the server never sent it: no code path called
it, and no client — this project's own included — listened for it. A hub method that fails
answers the caller's invocation with the failure, and a connection that cannot be scoped to a
project is refused at connect, so there was nothing for an out-of-band error event to carry. The
interface method, its message type, and the documentation row are gone; a client that registered
a handler for it loses a handler that never fired. - The
FluentValidation.AspNetCoredependency of the service, which no code used and which its
authors have deprecated, along with three package pins nothing referenced (FluentValidation,
Humanizer.Core,CsvHelper). No behaviour changes; the container image simply carries less. - The three
AspNetCore.HealthChecks.*packages (NpgSql, Redis, UI.Client), whose line stopped at
9.0.0. The database and Redis checks are now the service's own, and they probe the connection the
service itself holds — theNpgsqlDataSourceand the Redis multiplexer — rather than a
connection string of their own, so a probe cannot report on a database the service is not using.
The health endpoints answer the same JSON document as before (status,totalDuration, and one
entriesmember per check withstatus,duration,tags,data, and, when set,
descriptionandexception); it is now written by the service and pinned by tests.
Fixed
-
A row wider than a NOTIFY payload could not be written. The change-notification trigger put
the whole row into thepg_notifypayload, and PostgreSQL caps a payload at 8,000 bytes — the
error raised inside the AFTER ROW trigger and aborted the statement, so atextvalue of a few
thousand characters answered500and was never stored, subscribers or not. The trigger now
sends only the row's key and the service reads the row back when it handles the notification,
through the same doorGET /api/data/{table}/{id}uses — sodatain aRecordCreated/
RecordUpdatedevent and in a webhook delivery now carries exactly what that request would
return (logical names, system columns, decrypted values), and there is no width at which a
write starts failing. Two consequences are documented under WebSocket:datais the row as it
stands when read back, not the image the notifying statement wrote, and a row deleted before
the read arrives with an emptydata. A webhookfilteris compared against that row as it
would appear on the wire, so a filter written from a REST response matches the row it was
written from — the matcher used to compare only the payload's own JSON values and would have
matched nothing once the row was read back
(Verified-by: MorphHubTests.A_row_wider_than_a_notify_payload_is_stored_and_broadcast_whole,
WebhookFilterMatcherTests.Matches_RowReadBackAsClrValues_ComparesAsItWouldOnTheWire). -
Consecutive changes could be broadcast out of commit order. Notifications were handled inside
the database driver's event handler — anasync void— so the handlers for two commits overlapped
on their awaits and either could reach subscribers and webhooks first. PostgreSQL delivers
notifications in commit order and a subscriber has nothing else to tell two changes to one row
apart; the service now hands every notification to a bounded queue and one consumer handles them
in that order, which also puts backpressure on a bulk write instead of starting a handler per
row. The listener's connection now sends a keepalive after 30 idle seconds, so an idle drop is
noticed and repaired before the next change instead of by it
(Verified-by: MorphHubTests.Consecutive_changes_to_one_row_are_broadcast_in_commit_order). -
docs/API.mdpromised every subscriber every change; delivery is at most once. The
WebSocket section now states the delivery contract as it is — commit order, at most once, no
redelivery across a listener or connection reconnect, no gap signal — and how to catch up
(_updated_at; deletions leave nothing to catch up from). -
The .NET client's real-time subscription never received a change.
MorphDB.Client's
RealtimeClientlistened for aReceiveChangeevent carrying three strings, which the hub has
never sent — it broadcastsRecordCreated,RecordUpdatedandRecordDeleted, one message
object each, asdocs/API.mdhas said all along.Subscribesucceeded and then nothing arrived,
with no error, and no test drove the client's side of this door. The client now listens for the
three events the hub sends, the change kind comes from the event rather than from parsing a
string,Datavalues arrive as .NET values the same way every REST response's do, and the
event's own timestamp and record id are passed through instead of being reconstructed on
arrival. A test now drives the whole path against a running server, and a second one holds the
event names the client registers to the hub's client interface
(Verified-by: RealtimeClientTests.An_insert_reaches_the_subscriber_with_the_row_it_created).
The twoChangeNotificationmodel changes that ride along are listed under Breaking. -
The client's
HttpMessageHandleroption did not reach the real-time connection. It is
documented for proxy and test scenarios and every REST call honoured it; the hub connection built
its own handler and went around whatever the option named. It now uses the same handler, without
taking over its lifetime. -
An
X-Project-Idthat failed to parse was answered as if it had never been sent. The header
and an authenticated claim both collapsed into the sameGuid?, so "no project id" and "a project
id that is not a GUID" produced the identical400 MISSING_PROJECT— a caller who mistyped their
project id was told to send a header it had already sent. A header that fails to parse now answers
400 INVALID_PROJECT_ID, naming what was sent. -
The real-time hub made the same misdiagnosis at connect time.
MorphHubparsed
X-Project-Idon its own instead of sharing the rule above, so a connection whose header failed to
parse was refused with the same generic "no project" message as a connection that sent no header
at all. It now shares the one resolution rule REST/GraphQL use and reports the same distinction —
the two duplicate implementations were exactly how this drifted apart the first time
(Verified-by: MorphHubTests.Connect_WithMalformedProjectHeader_ShouldBeRefused). -
A data or schema request against a project that does not exist answered
TABLE_NOT_FOUND.
ProjectNotFoundExceptionalready existed but was only thrown from the project-management routes;
every other project-scoped route resolved a table inside a schema that was never there and reported
the table missing. It now answers404 PROJECT_NOT_FOUND— checked only on that error path, so a
request whose project and table both exist pays no extra query. -
That
404's own message echoed the project id.ProjectNotFoundExceptionquoted it in text
(Project with ID '…' not found.), which the hidden-layer principle this codebase already applies
to table and column names does not allow for an id a caller must never forward from an end user.
The message now says onlyProject not found.; the id is still on the exception'sProjectId
property for a caller or a log that needs it
(Verified-by: ErrorSurfaceContractTests.Query_AgainstNonexistentProject_IsA404_WithoutEchoingTheGuid). -
The batch example was not valid JSON. The
POST /api/batch/databody in the reference showed
an upsert'sdataas{...}— shorthand a reader can see through but a parser cannot, so the
example could not be sent as written even after filling in the elided id. It now shows a real
object. Found by a gate that now sends every documented request to a running server, rather than
by a reader who copied it. -
Formula columns and the encryption routes were undocumented. A column declaration has taken a
formulaobject (expression, return type) since the feature shipped, and five routes under
/api/security/encryption/*report and rotate encryption keys, butdocs/API.mdmentioned
neither: a consumer could not learn the expression syntax, the function set, that a formula
column is virtual, or that the rotation routes answer503until a master key is configured. Both
now have a section. The encryption section also states plainly that no request field marks a
single column as encrypted — the choice is the service-wideEncryptAllByDefaultsetting. -
A schema update that omitted
versionwas answered409 SCHEMA_VERSION_CONFLICT.versionis
documented as the one required field ofPATCH /api/schema/tables/{name},PATCH /api/schema/columns/{id}andPOST /api/schema/batch, but it was bound as a plain integer, so an
omitted version was compared as version 0 and refused with the same code as a real lost race. It
is required at binding now: a request without it is400 INVALID_ARGUMENTnaming the member. -
A malformed request body was described in .NET terms. The binding error for an unknown member,
a missing required member or a value of the wrong kind quoted the implementation's type names
(MorphDB.Service.Models.Api.…,System.Int32) — identifiers a consumer cannot act on. The same
400 INVALID_ARGUMENTnow says what was sent and what is accepted, in the words of the wire
contract:Unknown member 'filters' (at $.filters). Supported members: filter, orderBy, …. -
The development
docker-compose.ymlcollided with anything else on the machine. Its four
services carried fixedcontainer_names, so a second checkout (ordocker compose -p) could not
start beside the first, and its host ports were hard-wired, so a Redis or PostgreSQL already on
5432/6379 failed the wholeup. Names are gone and the host ports readMORPHDB_PORT,
MORPHDB_PG_PORT,MORPHDB_REDIS_PORTandMORPHDB_PGADMIN_PORT. The file now says what it is —
the source-build development bundle — and the README says which compose to copy to run the
published image, that the Kestrel development port (5400) is not the container port (8080), and
that a project name is slugged and must be unique. The column type catalog moved from
docs/ARCHITECTURE.md, where it listed eight of the twenty-five accepted types, to
docs/API.md, complete. -
A row read with a
selectthat left out_idwas answered with an all-zero id. REST's
GET /api/data/{table}?select=…andPOST …/querybuilt the envelopeidfrom the row and, when
the projection did not carry_id, filled it with00000000-0000-0000-0000-000000000000— three
distinct rows came back with the same well-formed id, and nothing said so._idis now always
fetched, so every row'sidis its own. The same fill-in stood behind the batch, upsert, OData and
GraphQL record ids and the GraphQL page cursor, where the row always carries_idbut a missing one
would have been hidden the same way; those now fail loudly instead of answering with a placeholder.
A GraphQLaftercursor the server did not issue is refused withINVALID_CURSORinstead of being
read as the empty id and silently restarting the page. -
The documented real-time subscribe call was refused by the server.
docs/API.mdopens the
WebSocket section withconnection.invoke("Subscribe", "customers"), and running it as written
answeredInvocation provides 1 argument(s) but target expects 2— the first thing a real-time
consumer does, and it could not be done. Two gates now hold that path: one compares the argument
count of every invocation in the documented example to the parameters the named hub method binds,
and one makes the documented call itself, as written, against a live hub. -
Real-time change events carried physical column names and
project_id. Every other surface
(REST, GraphQL, export, views) speaks logical column names only; the SignalR broadcast and
webhook payload/filter matching used the trigger's raw row (to_jsonb(NEW), physical names) with
no translation step. A subscriber had no way to mapcol_e9d8c7b6back to the column it declared,
and a webhookFilterwritten in the same logical vocabulary the registration API itself uses
could never match anything, so a filtered webhook silently never fired. Both paths now translate
through the same table metadata every other surface already reads. Wire change:datain
RecordCreated/RecordUpdatedand a webhook payload'sdataare now keyed by logical column
name and no longer carryproject_id; no known consumer relied on the physical keys. -
A webhook delivery carried a
previousfield that nothing ever populated. No code path set
it, so every delivery ever sent carried"previous": null— a field a receiver could branch on
and never see a value from. It is gone. Wire change:previousno longer appears in a
delivery body. -
The delivered payload's record id was not documented. The Webhook section showed a
four-field example while a delivery carries five, and the field it left out is the one naming the
row an event is about — a receiver had to inspect a live delivery to find it. The example now
shows what is actually sent (the field ships asrecordId— see the camelCase entry above) and
names the valueseventsaccepts. A parity gate now derives all three — request fields,
event vocabulary, payload fields — from the binding model, the enum, and the delivery
serializer, so the documentation cannot drift from them again. -
The delivery section now states that a subscription can receive a change committed before it
was made. Subscribers are resolved when a change is delivered, not when it committed, so a
subscription made while the service is working through a backlog receives what is still in it.
The documented order (read first, subscribe second) was previously written as if the two could
not overlap.
Dependencies
Dapperto 2.1.86 andxunit.v3to 4.0.1 — patch releases; no behavior change.- ASP.NET Core packages to 10.0.12 (JWT bearer, SignalR client, MVC testing, Redis cache),
Microsoft.NET.Test.Sdkto 18.10.0, andStackExchange.Redisto 3.2.0 — the last carries a
security fix (CVE-2026-62900) and moves cluster discovery fromCLUSTER NODEStoCLUSTER SLOTS;
the cache is an optional dependency and no code here touches either. No behavior change. HotChocolate.AspNetCoreandHotChocolate.Datato 16.6.6. Two patches on the same line; the
only listed change is a diagnostics span on the persisted-operation endpoint, which this server
does not expose. No behavior change.
Internal
-
scripts/start-dev.ps1 -Headlessand astop-dev.ps1that stops only what was started.
A headless run brings PostgreSQL up in a compose project of its own, runs the Release build of
the service in the background on a port of its own, and records the process id, project and
ports in.dev-state.json;stop-dev.ps1stops that process by id and that project, and
nothing else. It used to stop everydotnetprocess whose command line looked like the
service or a watcher, which also matches the build servers a concurrentdotnet buildis
using. Driving the service from a script no longer needs the setup to be assembled by hand.
Two leftovers went with it: the scripts still calledPOST /api/dev/bootstrap, an endpoint
removed with the authentication machinery, and waited for PostgreSQL by a container name compose
no longer assigns, so that wait always ran its 30 seconds out. -
A gate on every declared real-time event having a publisher. The hub's client interface is
what the documentation and the supported .NET client are written against, and a method on it
with noClients….X(…)call site anywhere in the service compiles perfectly while the event it
declares can never arrive. Four contracts of that shape had been found by hand; a test now
relates the declared events to the publishing call sites, so the next one fails the build
instead of waiting for a subscriber to notice the silence.