Stable C execution ABI

One database-owned response queue serves core operations and enabled plugins. New operations add schemas and generated language methods. The C execution interface stays fixed.

This is a proposal. The attached Rust, Python, and Go examples describe generated APIs that are not implemented yet.

Replacement

Existing interfaceReplacement
Query cursors, page-next calls, and subscription handlesRequests producing pages or continuing updates
Subscription set_notify and proposed waitsetsOne optional wake callback per database
Events owners, subscription IDs, and repeated receive waitsStreaming requests owned by the database
Mutation and plugin-call entry pointsTyped request documents submitted through one function
Separate result handles and diagnostic accessors on these pathsResponse documents containing results and errors

Database lifecycle, licensing, documents, builders, codecs, and standalone expression/macro construction remain separate. Byte-stream exports need explicit bounded-chunk contracts before removal. There is no compatibility layer or second implementation behind the old execution functions.

C interface

typedef void (*stardust_wake)(void *ctx);

int64_t stardust_submit(
    stardust_db *db, const stardust_document *requests,
    int64_t *firstRequestId);

int64_t stardust_poll(
    stardust_db *db, int64_t max_wait_ticks,
    stardust_document **output);

int64_t stardust_cancel(stardust_db *db, int64_t requestId);

The existing database-open options gain responses (a limits document), wake, and wake_ctx. They are fixed at open. Limits and future execution options stay in documents. The existing database handle owns the queue. No channel handle or separate queue lifetime is needed.

submit takes an array of input frames, including one-item batches. New requests omit requestId. The database generates IDs and returns the first one through firstRequestId. Generated language methods hide IDs inside typed request objects.

For example, one submission starts two subscriptions and one push stream. None of these new requests contains a requestId field:

[
  {type querySubscribe path todos final true}
  {type eventsSubscribe pattern todos.changed final true}
  {type eventsPush subject todos.import data {id 1}}
]

For N new requests, submission atomically reserves one contiguous ID block. The ith new request receives firstRequestId + i, starting at zero. Concurrent submissions cannot interleave their blocks. No ID array is returned, and IDs are not reused during the database's lifetime. An ID allocation that would overflow is rejected without wrapping.

If the library returns firstRequestId = 17, the query subscription is 17, the event subscription is 18, and the push stream is 19. These are assigned IDs, not caller-selected values.

The next submission feeds and closes that existing push stream. Its frames therefore include the assigned ID:

[
  {requestId 19 type eventsPush data {id 2}}
  {requestId 19 type eventsPush final true}
]

This call returns firstRequestId = 0 because it starts no new requests. requests.c demonstrates deriving the actual stream ID from the first call, rather than hard-coding 19. Generated Rust/Python/Go methods perform these steps internally and store the assigned ID in their request object.

Continuation frames contain the previously assigned requestId, populated by the binding. They consume no new IDs. Mixed batches number only their new requests, in input order. Frames for the same stream are applied in array order. An empty batch or a batch containing only continuations returns OK with firstRequestId zero.

Admission is all-or-nothing: validate and reserve resources for every frame before accepting any. On submission failure, no frame is accepted and firstRequestId is zero. Accepted operations execute independently, so batch submission is not a database transaction. Before return, submission copies input values or keeps ownership references. Submission does not wait for execution.

poll returns an owned native array document. Release it with the existing document-release function. Zero wait checks immediately. Positive waits use 100-nanosecond ticks and wait only for the first response, not a full batch. Expiration returns OK with []. STARDUST_END means the database is closed and its queue is empty.

Accepted operation failures appear in responses. C statuses report submission or polling failures that cannot return such a response. An open database with no active requests still returns [].

Response and streaming contract

[
  {requestId 17 type queryPage result {sequence 1 transaction 42 lastPage true rows [{id 1}]}}
  {requestId 18 type eventsMessage result {subject todos.changed sequence 7 data {id 1}}}
  {requestId 19 type completed final true error {code failed message Failed}}
]

Each item has requestId, a camelCase type, and exactly one of result or error. A batch can contain any mixture. Errors contain code, message, and optional structured details. result null remains explicit because it identifies success.

MarkerMeaning
Request final trueInput is closed. Output can continue.
Response final trueThe request ends. Further input is rejected.
Query page lastPage trueThis snapshot ends. A subscription can continue.

Streaming input reuses its assigned request ID through submit. Generated push and finish methods fill that metadata automatically. Single-input methods automatically send final true, including subscriptions whose output continues. Cancellation aborts both directions. Collecting the final response releases request state, but does not recycle its ID.

Producers omit metadata equal to declared schema defaults: final and lastPage default to false, while page, superseded, and dropped default to zero. Fresh Go/Odin result structs already have those values. Rust/Python types declare the defaults. User payload values are preserved.

Defaults are decoder contracts. JSON Schema validation does not insert them.

Pages retain their snapshot sequence and transaction. One request finishes a snapshot's pages before starting another. Different requests can interleave. Batching does not change mutation atomicity.

Schemas and generated types

The effective schemas depend on the opened database. Their oneOf alternatives contain core types plus the enabled plugins' types, with any relevant startup constraints. The attached schema files illustrate a database with Events and Reactors enabled. They are not universal schemas for every database.

A core request exposes the effective request and response schemas:

[{type schema final true}]

Its schema response contains result.requests and result.responses. Core completion/error responses remain available when a requested plugin is disabled or unknown. Such requests can therefore return a schema-valid completed error, such as unavailable.

The operation registry associates input schemas, output schemas, and each direction's lifetime. Generation produces language request types, response variants, and typed methods from those contracts. Generic bindings can ship types for all supported plugins. A method's presence does not mean its plugin is enabled.

Database-specific SDK generation uses the effective schemas and stored definitions to produce a further typed layer over the bindings. It remains a development plugin, not a network client. Both layers use native documents without a JSON text round trip.

Threading and ownership

Any application thread may submit or cancel. Bounded library workers execute accepted work independently of polling. Exactly one dispatcher polls each database and routes responses by requestId. It has no native-thread affinity, and the queue is not per thread.

The wake callback can run on any native thread, including before submission returns. Calls are serialized per database and occur outside internal locks. The callback only signals the host scheduler. It must not call Stardust or run application handlers. Keep wake_ctx valid until database close returns.

Blocking callers can wait in native poll. Async bindings install a waiter before a zero-wait check, then await a latched wake signal. An empty check atomically arms notification. Idle requests do not occupy a native thread each.

Generated methods share one dispatcher: a goroutine in Go, a task on the owning asyncio loop in Python, or a Rust dispatcher. Application tasks receive typed batches through bounded per-request buffers. They do not compete to poll the native queue. Polling and decoding work must be bounded or use bounded helpers outside event-loop/runtime workers.

The binding serializes submission and routing registration against response dispatch. It derives each new ID from the returned block and installs all mappings before dispatching their responses. The native wake callback only signals and never acquires this registration lock. No application handler runs under that lock. This handles responses that arrive before submit returns.

Raw consumers implement their own dispatcher and cannot poll alongside the managed binding dispatcher. Queue, batch, input, and language-buffer limits are fixed or bounded. A slow consumer follows its operation's overflow policy or is canceled without blocking every other request.

Database close cancels work, wakes polls, and waits for native calls and callbacks. No callback runs after close returns. To collect queued responses afterward, retain a database reference before close, poll until END, then release it. Returned documents remain valid independently. Bindings perform blocking cleanup outside async runtime workers.

Long-term boundary

The C ABI carries native documents and manages execution, waiting, cancellation, and ownership. Operation names, input shapes, output variants, and plugin selection belong to schemas. Adding an operation extends contracts and generated APIs without adding execution exports or another async mechanism.

New operation errors add document codes, not C status values. Pages, live updates, and client streaming share the same request IDs, completion markers, and response batches.

This stabilizes the binary execution boundary. Schemas remain public operation contracts, generated and validated from the same registry.

The attached header, language syntax, schemas, and DUST examples have artifact checks. Runtime behavior still requires implementation and native binding tests.

requests.h, 43 lines
#ifndef STARDUST_REQUESTS_PROPOSAL_H
#define STARDUST_REQUESTS_PROPOSAL_H

#include <stdint.h>

/* Existing opaque types. The database owns one response queue. */
typedef struct stardust_db stardust_db;
typedef struct stardust_document stardust_document;
typedef void (*stardust_wake)(void *ctx);

#ifdef __cplusplus
extern "C" {
#endif

/* Open options gain responses, wake, and wake_ctx. See README.md. */

/* Submit an array of frames. Admission accepts all frames or none. */
/* New requests omit requestId and receive one contiguous ID block. */
/* firstRequestId is zero on failure or when no new requests are present. */
int64_t stardust_submit(
    stardust_db *db,
    const stardust_document *requests,
    int64_t *firstRequestId
);

/* One dispatcher polls. OK transfers an array document, possibly empty. */
int64_t stardust_poll(
    stardust_db *db,
    int64_t max_wait_ticks,
    stardust_document **output
);

/* Any application thread may cancel. Collect its final response through poll. */
int64_t stardust_cancel(
    stardust_db *db,
    int64_t requestId
);

#ifdef __cplusplus
}
#endif

#endif
requests.c, 68 lines
/* Proposed C usage. Native submit/poll/cancel are not implemented yet.
 * The caller owns an open database, polls its responses, and closes it.
 */
#include <inttypes.h>
#include <stddef.h>
#include <stdio.h>

#include "stardust.h"
#include "requests.h"

static int64_t submit_dust(
    stardust_db *db,
    const char *source,
    size_t length,
    int64_t *firstRequestId
) {
    *firstRequestId = 0;
    stardust_document *requests = NULL;
    stardust_bytes bytes = {
        .data = (const uint8_t *)source,
        .length = (int64_t)length,
    };
    int64_t status = stardust_parse(bytes, STARDUST_DUST, &requests);
    if (status == STARDUST_OK) {
        status = stardust_submit(db, requests, firstRequestId);
    }
    stardust_document_release(requests);
    return status;
}

/* firstRequestId identifies the first batch even if the later call fails.
 * Its offsets are 0=query subscription, 1=event subscription, 2=push stream.
 * The caller keeps responsibility for all accepted requests.
 */
int64_t submit_example(stardust_db *db, int64_t *firstRequestId) {
    if (firstRequestId == NULL) {
        return STARDUST_INVALID_ARGUMENT;
    }

    /* No caller-selected requestId appears in any new request. */
    const char starts[] =
        "[{type querySubscribe path todos final true}"
        " {type eventsSubscribe pattern todos.changed final true}"
        " {type eventsPush subject todos.import data {id 1}}]";
    int64_t status = submit_dust(db, starts, sizeof(starts) - 1, firstRequestId);
    if (status != STARDUST_OK) {
        return status;
    }

    /* Use the assigned block. If its first ID is 17, this stream's ID is 19. */
    int64_t pushRequestId = *firstRequestId + 2;
    char frames[256];
    int length = snprintf(
        frames,
        sizeof(frames),
        "[{requestId %" PRId64 " type eventsPush data {id 2}}"
        " {requestId %" PRId64 " type eventsPush final true}]",
        pushRequestId,
        pushRequestId
    );
    if (length < 0 || (size_t)length >= sizeof(frames)) {
        return STARDUST_FAILED;
    }

    /* Continuations use the assigned ID. They allocate no new request IDs. */
    int64_t continuationFirstId = 0;
    return submit_dust(db, frames, (size_t)length, &continuationFirstId);
}
requests.rs, 90 lines
//! Proposed generated binding usage. These methods are not implemented yet.
//! One database dispatcher polls native responses and routes typed batches.
//! subscribe omits requestId. Native submit assigns the ID, and the typed
//! subscription stores it for subsequent operations. Callers never choose IDs.

use std::{error::Error, time::Duration};

use serde::Deserialize;
use stardust::{AsyncDatabase, Database, EventsMessage, EventsSubscribeRequest, Outcome};

type ExampleResult<T> = Result<T, Box<dyn Error + Send + Sync>>;

#[derive(Debug, Deserialize)]
struct Todo {
    id: i64,
    title: String,
}

fn print_batch(batch: Vec<Outcome<EventsMessage<Todo>>>) -> ExampleResult<()> {
    for response in batch {
        match response {
            Outcome::Success { result, .. } => {
                println!(
                    "{}: {} {}",
                    result.subject, result.data.id, result.data.title
                );
            }
            Outcome::Failure { error, .. } => return Err(error.into()),
        }
    }
    Ok(())
}

pub fn consume_blocking(database: &Database) -> ExampleResult<()> {
    let mut subscription = database
        .events()
        .subscribe::<Todo>(EventsSubscribeRequest {
            pattern: "todos.changed".into(),
        })?;

    loop {
        // This waits on routed responses, not on a separate native queue.
        let Some(batch) = subscription.poll(Duration::from_secs(1))? else {
            return Ok(());
        };
        if batch.is_empty() {
            continue;
        }
        print_batch(batch)?;
        subscription.close()?;
        return Ok(());
    }
    // Drop requests cancellation on an early return. The dispatcher keeps the
    // request state until its final response arrives. The database stays open.
}

pub async fn consume_async(database: &AsyncDatabase) -> ExampleResult<()> {
    let mut subscription = database
        .events()
        .subscribe::<Todo>(EventsSubscribeRequest {
            pattern: "todos.changed".into(),
        })
        .await?;

    let result: ExampleResult<()> = async {
        loop {
            // This future waits for typed batches routed by the dispatcher.
            // Idle subscriptions reserve no native or blocking-helper thread.
            let Some(batch) = subscription.poll(Duration::from_secs(1)).await? else {
                return Ok(());
            };
            if batch.is_empty() {
                continue;
            }
            return print_batch(batch);
        }
    }
    .await;

    // Close waits for this request's final response. It does not close the DB.
    let cleanup = subscription.close().await;
    result?;
    cleanup?;
    Ok(())
}

// The caller owns database shutdown. Async database close performs blocking
// native cleanup outside runtime workers. Canceling this consumer future drops
// its subscription and requests cancellation, while the database dispatcher
// remains responsible for collecting the final response.
requests.py, 72 lines
"""Proposed generated binding usage. These methods are not implemented yet.

The database owner selects blocking or async behavior when opening it.
One binding dispatcher polls its native response queue. Typed subscriptions
receive separate batches without creating native queues or polling threads.
subscribe omits requestId. Native submit assigns the ID, and the subscription
stores it for subsequent operations. Application code never chooses an ID.
"""

from dataclasses import dataclass
from datetime import timedelta

from stardust import (
    AsyncDatabase,
    Database,
    EventsMessage,
    EventsSubscribeRequest,
    Failure,
    Outcome,
)


@dataclass
class Todo:
    id: int
    title: str


def print_batch(batch: list[Outcome[EventsMessage[Todo]]]) -> None:
    for response in batch:
        if isinstance(response, Failure):
            raise RuntimeError(f"{response.error.code}: {response.error.message}")
        message = response.result
        print(message.subject, message.data.id, message.data.title)


def consume_blocking(database: Database) -> None:
    # Closing the subscription cancels only its request. The database stays open.
    with database.events.subscribe(
        EventsSubscribeRequest(pattern="todos.changed"),
        payload_type=Todo,
    ) as subscription:
        while True:
            # None means this typed request ended. [] means the wait expired.
            batch = subscription.poll(max_wait=timedelta(seconds=1))
            if batch is None:
                return
            if batch:
                print_batch(batch)
                return


async def consume_async(database: AsyncDatabase) -> None:
    # The database and its dispatcher belong to the current asyncio event loop.
    async with database.events.subscribe(
        EventsSubscribeRequest(pattern="todos.changed"),
        payload_type=Todo,
    ) as subscription:
        while True:
            # This awaits routed host values. The native callback only signals
            # the dispatcher through loop.call_soon_threadsafe.
            batch = await subscription.poll(max_wait=timedelta(seconds=1))
            if batch is None:
                return
            if batch:
                print_batch(batch)
                return

# Both functions borrow an open database. Its owner must close it on shutdown,
# including when a consumer fails. Async close waits for native cleanup outside
# the event-loop thread. The async subscription context cancels and completes
# its own request without closing the database.
requests.go, 66 lines
// Proposed generated binding usage. These methods are not implemented yet.
package example

import (
	"context"
	"errors"
	"fmt"
	"io"
	"time"

	"stardust"
)

type Todo struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
}

// Consume prints one batch, then closes this subscription.
// Other goroutines can consume other typed requests on the same database.
// One binding dispatcher owns native polling. The database owner handles shutdown.
func Consume(ctx context.Context, database *stardust.Database) (returnErr error) {
	// This method submits a new frame without requestId. Native submit assigns
	// the ID, which the subscription stores internally for polling and close.
	subscription, err := stardust.SubscribeEvents[Todo](
		ctx,
		database,
		stardust.EventsSubscribeRequest{
			Pattern: "todos.changed",
		},
	)
	if err != nil {
		return err
	}
	defer func() {
		returnErr = errors.Join(returnErr, subscription.Close())
	}()

	for {
		// This waits on the Go scheduler for this request's buffered responses.
		// It does not call native poll or block one native thread per request.
		batch, err := subscription.Poll(ctx, time.Second)
		if errors.Is(err, io.EOF) {
			return nil
		}
		if err != nil {
			return err
		}
		if len(batch) == 0 {
			continue
		}

		// batch is []stardust.Outcome[stardust.EventsMessage[Todo]].
		for index := range batch {
			response := batch[index]
			if response.Error != nil {
				return response.Error
			}

			message := response.Result
			fmt.Printf("%s: %d %s\n", message.Subject, message.Data.ID, message.Data.Title)
		}

		return nil
	}
}
requests.schema.json, 213 lines
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Example Stardust requests: core, Events, and Reactors",
  "definitions": {
    "query": {
      "type": "object",
      "required": [
        "type",
        "path",
        "final"
      ],
      "properties": {
        "type": {
          "const": "query"
        },
        "path": {
          "type": "string",
          "minLength": 1
        },
        "parameters": {
          "type": "object"
        },
        "pageSize": {
          "type": "integer",
          "minimum": 1
        },
        "final": {
          "const": true
        }
      },
      "additionalProperties": false
    },
    "querySubscribe": {
      "type": "object",
      "required": [
        "type",
        "path",
        "final"
      ],
      "properties": {
        "type": {
          "const": "querySubscribe"
        },
        "path": {
          "type": "string",
          "minLength": 1
        },
        "parameters": {
          "type": "object"
        },
        "pageSize": {
          "type": "integer",
          "minimum": 1
        },
        "final": {
          "const": true
        }
      },
      "additionalProperties": false
    },
    "eventsSubscribe": {
      "type": "object",
      "required": [
        "type",
        "pattern",
        "final"
      ],
      "properties": {
        "type": {
          "const": "eventsSubscribe"
        },
        "pattern": {
          "type": "string"
        },
        "final": {
          "const": true
        }
      },
      "additionalProperties": false
    },
    "reactorsStatus": {
      "type": "object",
      "required": [
        "type",
        "final"
      ],
      "properties": {
        "type": {
          "const": "reactorsStatus"
        },
        "final": {
          "const": true
        }
      },
      "additionalProperties": false
    },
    "eventsPush": {
      "oneOf": [
        {
          "$ref": "#/definitions/eventsPushStart"
        },
        {
          "$ref": "#/definitions/eventsPushFrame"
        }
      ]
    },
    "eventsPushStart": {
      "type": "object",
      "required": [
        "type",
        "subject"
      ],
      "properties": {
        "type": {
          "const": "eventsPush"
        },
        "subject": {
          "type": "string"
        },
        "data": {},
        "final": {
          "type": "boolean",
          "default": false
        }
      },
      "additionalProperties": false
    },
    "eventsPushFrame": {
      "type": "object",
      "required": [
        "type",
        "requestId"
      ],
      "properties": {
        "type": {
          "const": "eventsPush"
        },
        "data": {},
        "final": {
          "type": "boolean",
          "default": false
        },
        "requestId": {
          "type": "integer",
          "minimum": 1,
          "maximum": 9223372036854775807,
          "$comment": "Assigned by the database when the stream starts. Generated bindings populate continuation frames."
        }
      },
      "anyOf": [
        {
          "required": [
            "data"
          ]
        },
        {
          "required": [
            "final"
          ],
          "properties": {
            "final": {
              "const": true
            }
          }
        }
      ],
      "additionalProperties": false
    },
    "schema": {
      "type": "object",
      "required": [
        "type",
        "final"
      ],
      "properties": {
        "type": {
          "const": "schema"
        },
        "final": {
          "const": true
        }
      },
      "additionalProperties": false
    },
    "request": {
      "oneOf": [
        {
          "$ref": "#/definitions/query"
        },
        {
          "$ref": "#/definitions/querySubscribe"
        },
        {
          "$ref": "#/definitions/eventsSubscribe"
        },
        {
          "$ref": "#/definitions/reactorsStatus"
        },
        {
          "$ref": "#/definitions/eventsPush"
        },
        {
          "$ref": "#/definitions/schema"
        }
      ]
    }
  },
  "$comment": "Example schema for a database with Events and Reactors enabled. Generate the effective schema from core and the plugins enabled on the opened database.",
  "type": "array",
  "items": {
    "$ref": "#/definitions/request"
  }
}
responses.schema.json, 220 lines
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Example Stardust responses: core, Events, and Reactors",
  "type": "array",
  "items": {
    "$ref": "#/definitions/response"
  },
  "definitions": {
    "error": {
      "type": "object",
      "required": [
        "code",
        "message"
      ],
      "properties": {
        "code": {
          "type": "string",
          "minLength": 1
        },
        "message": {
          "type": "string"
        },
        "details": {
          "type": "object"
        }
      },
      "additionalProperties": false
    },
    "response": {
      "type": "object",
      "required": [
        "requestId",
        "type"
      ],
      "properties": {
        "requestId": {
          "type": "integer",
          "minimum": 1,
          "maximum": 9223372036854775807
        },
        "type": {
          "type": "string",
          "pattern": "^[a-z][A-Za-z0-9]*$"
        },
        "final": {
          "type": "boolean",
          "default": false
        },
        "result": {},
        "error": {
          "$ref": "#/definitions/error"
        }
      },
      "additionalProperties": false,
      "allOf": [
        {
          "oneOf": [
            {
              "required": [
                "result"
              ],
              "not": {
                "required": [
                  "error"
                ]
              }
            },
            {
              "required": [
                "error"
              ],
              "not": {
                "required": [
                  "result"
                ]
              }
            }
          ]
        },
        {
          "oneOf": [
            {
              "properties": {
                "type": {
                  "const": "queryPage"
                },
                "result": {
                  "$ref": "#/definitions/queryPage"
                }
              }
            },
            {
              "properties": {
                "type": {
                  "const": "eventsMessage"
                },
                "result": {
                  "$ref": "#/definitions/eventsMessage"
                }
              }
            },
            {
              "properties": {
                "type": {
                  "const": "reactorsStatus"
                },
                "result": {
                  "type": "object"
                }
              }
            },
            {
              "properties": {
                "type": {
                  "const": "completed"
                },
                "final": {
                  "const": true
                },
                "result": {
                  "type": "null"
                }
              },
              "required": [
                "final"
              ]
            },
            {
              "properties": {
                "type": {
                  "const": "schema"
                },
                "result": {
                  "$ref": "#/definitions/schemaResult"
                }
              }
            }
          ]
        }
      ]
    },
    "queryPage": {
      "type": "object",
      "required": [
        "sequence",
        "transaction",
        "rows"
      ],
      "properties": {
        "sequence": {
          "type": "integer",
          "minimum": 1
        },
        "transaction": {
          "type": "integer",
          "minimum": 0
        },
        "superseded": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        },
        "page": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        },
        "lastPage": {
          "type": "boolean",
          "default": false
        },
        "rows": {
          "type": "array"
        }
      },
      "additionalProperties": false
    },
    "eventsMessage": {
      "type": "object",
      "required": [
        "subject",
        "sequence",
        "data"
      ],
      "properties": {
        "subject": {
          "type": "string"
        },
        "sequence": {
          "type": "integer",
          "minimum": 1
        },
        "dropped": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        },
        "data": {}
      },
      "additionalProperties": false
    },
    "schemaResult": {
      "type": "object",
      "required": [
        "requests",
        "responses"
      ],
      "properties": {
        "requests": {
          "type": "object"
        },
        "responses": {
          "type": "object"
        }
      },
      "additionalProperties": false
    }
  },
  "$comment": "Example schema for a database with Events and Reactors enabled. Generate the effective schema from core and the plugins enabled on the opened database."
}
responses.dust, 17 lines
[
  {requestId 17
   type      queryPage
   result    {sequence    1
              transaction 42
              lastPage    true
              rows        [{id 1}]}}
  {requestId 18
   type      eventsMessage
   result    {subject  todos.changed
              sequence 7
              data     {id 1}}}
  {requestId 19
   type      completed
   final     true
   error     {code    failed
              message Failed}}]