Skip to content

MorphDB 0.12.0

Choose a tag to compare

@github-actions github-actions released this 13 Sep 13:52
· 19 commits to main since this release

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 serialized snake_case; a receiver read record_id where 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
    becomes recordId, and the other four field names are unchanged because they are single words.
    Wire change: a receiver that reads record_id must read recordId. It lands in the same
    release as the previous removal below, so a receiver adjusts to the payload once rather than
    twice.
  • An export request has no filter or orderBy. 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
    through GET /api/data/{table}?filter=... and assemble the file yourself — that route does
    filter, and always has (Verified-by: DataApiTests.Query_WithFilter_ShouldReturnFilteredResults).
  • The Subscribe hub 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; subscribe there 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.
  • ChangeNotification in the .NET client loses OldData, and its RecordId is 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 saw null; it is gone rather than kept as a field that lies.
    RecordId is Guid?, as it is on the event, instead of a Guid that read Guid.Empty whenever
    no id was carried. Code that references OldData, or assigns RecordId to a non-nullable Guid,
    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.SubscribeAsync accepts a task-returning callback. A second overload takes
    Func<ChangeNotification, Task> alongside the existing Action<ChangeNotification>, the same
    pair SignalR's own HubConnection.On offers. An async lambda 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. The MorphDB.Client README's real-time example, which passed an async
    lambda to the Action overload and so compiled as async 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 the 0.11.x contract, marked archived in each
    README, and is not maintained, tested, or published — except for this release's own Subscribe
    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 and MorphDB.Client (.NET) — docs/COMPATIBILITY.md
    and docs/TESTING.md say so.

Removed

  • The GraphQL subscription root — onRecordCreated, onRecordUpdated, onRecordDeleted and
    onRecordChanged.
    The schema served them, docs/API.md documented 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 OnError client event. IMorphHubClient declared it and docs/API.md
    documented it with a code/message payload, 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.AspNetCore dependency 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 — the NpgsqlDataSource and 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
    entries member per check with status, duration, tags, data, and, when set,
    description and exception); 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 the pg_notify payload, and PostgreSQL caps a payload at 8,000 bytes — the
    error raised inside the AFTER ROW trigger and aborted the statement, so a text value of a few
    thousand characters answered 500 and 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 door GET /api/data/{table}/{id} uses — so data in a RecordCreated /
    RecordUpdated event 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: data is 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 empty data. A webhook filter is 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 — an async 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.md promised 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
    RealtimeClient listened for a ReceiveChange event carrying three strings, which the hub has
    never sent — it broadcasts RecordCreated, RecordUpdated and RecordDeleted, one message
    object each, as docs/API.md has said all along. Subscribe succeeded 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, Data values 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 two ChangeNotification model changes that ride along are listed under Breaking.

  • The client's HttpMessageHandler option 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-Id that failed to parse was answered as if it had never been sent. The header
    and an authenticated claim both collapsed into the same Guid?, so "no project id" and "a project
    id that is not a GUID" produced the identical 400 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. MorphHub parsed
    X-Project-Id on 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.
    ProjectNotFoundException already 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 answers 404 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. ProjectNotFoundException quoted 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 only Project not found.; the id is still on the exception's ProjectId
    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/data body in the reference showed
    an upsert's data as {...} — 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
    formula object (expression, return type) since the feature shipped, and five routes under
    /api/security/encryption/* report and rotate encryption keys, but docs/API.md mentioned
    neither: a consumer could not learn the expression syntax, the function set, that a formula
    column is virtual, or that the rotation routes answer 503 until 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-wide EncryptAllByDefault setting.

  • A schema update that omitted version was answered 409 SCHEMA_VERSION_CONFLICT. version is
    documented as the one required field of PATCH /api/schema/tables/{name}, PATCH /api/schema/columns/{id} and POST /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 is 400 INVALID_ARGUMENT naming 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_ARGUMENT now 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.yml collided with anything else on the machine. Its four
    services carried fixed container_names, so a second checkout (or docker 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 whole up. Names are gone and the host ports read MORPHDB_PORT,
    MORPHDB_PG_PORT, MORPHDB_REDIS_PORT and MORPHDB_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 select that left out _id was answered with an all-zero id. REST's
    GET /api/data/{table}?select=… and POST …/query built the envelope id from the row and, when
    the projection did not carry _id, filled it with 00000000-0000-0000-0000-000000000000 — three
    distinct rows came back with the same well-formed id, and nothing said so. _id is now always
    fetched, so every row's id is 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 _id but a missing one
    would have been hidden the same way; those now fail loudly instead of answering with a placeholder.
    A GraphQL after cursor the server did not issue is refused with INVALID_CURSOR instead 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.md opens the
    WebSocket section with connection.invoke("Subscribe", "customers"), and running it as written
    answered Invocation 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 map col_e9d8c7b6 back to the column it declared,
    and a webhook Filter written 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: data in
    RecordCreated/RecordUpdated and a webhook payload's data are now keyed by logical column
    name and no longer carry project_id; no known consumer relied on the physical keys.

  • A webhook delivery carried a previous field 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: previous no 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 as recordId — see the camelCase entry above) and
    names the values events accepts. 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

  • Dapper to 2.1.86 and xunit.v3 to 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.Sdk to 18.10.0, and StackExchange.Redis to 3.2.0 — the last carries a
    security fix (CVE-2026-62900) and moves cluster discovery from CLUSTER NODES to CLUSTER SLOTS;
    the cache is an optional dependency and no code here touches either. No behavior change.
  • HotChocolate.AspNetCore and HotChocolate.Data to 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 -Headless and a stop-dev.ps1 that 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.ps1 stops that process by id and that project, and
    nothing else. It used to stop every dotnet process whose command line looked like the
    service or a watcher, which also matches the build servers a concurrent dotnet build is
    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 called POST /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 no Clients….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.