Client
The client is what the views talk to, and what an app talks to when it wants data without a view. It turns the calls of a DatabaseSession into protocol requests, sends them through a transport and gives back the results or a DatabaseRequestError.
import { createDatabaseClient, DatabaseRequestError, useDatabaseClient } from '@adecore/database';
import type { Connection, DatabaseClient, DatabaseSession, DatabaseTransport, SchemaChange } from '@adecore/database';createDatabaseClient
const client = createDatabaseClient(transport, { createId });It returns a DatabaseClient. transport is a DatabaseTransport: (request: DatabaseRequest) => Promise<DatabaseResponse>. It carries the request to the host and resolves with the response. It rejects only when the channel itself failed, and the client turns that into a helper-unavailable error.
const transport: DatabaseTransport = (request) => window.database.request(request);DatabaseClientOptions has one field, createId, which makes the id of a request. It uses crypto.randomUUID() when left out. Pass your own in a test to get stable ids.
The client has these methods:
| Method | |
|---|---|
session(connection, channel?) | The session of a Connection on a channel ('' when left out). The same object on every call. |
test(config, options?) | Opens a connection and closes it again. Resolves with the ServerInfo. |
discover('docker', options?) | The running containers that look like database servers, as readonly DockerContainer[]. options.context picks a Docker context. |
sample(path, format, header, options?) | The first lines of a CSV or TSV file: { columns, rows }, all strings. |
notifySchemaChange(change) | Tells the listeners that the shape of a database changed. |
onSchemaChange(listener) | Calls the listener on every schema change. Returns the function that stops listening. |
disconnect(connectionId) | Closes every session of that connection, on every channel. |
dispose() | Closes every session. |
discover and sample need no connection. The host asks the app before it answers either; see Security.
session is cheap. It connects on the first request, not when you call it. Calling it again with the same id and an equal config returns the same session, and updates its name. A connection whose config changed gets a new session, and the old one closes. This is how editing a connection in a ConnectionManager makes the open views reconnect.
A channel is a second key next to the connection id. Each channel is a session of its own on the host, with its own connection and its own transaction, so work on one never lands inside another's transaction. The views share the default channel; a QueryConsole takes a channel of its own and closes its session when it unmounts. An app that runs something long, or that lets a person hold a transaction open, asks for its own channel: client.session(connection, 'import').
useDatabaseClient() reads the client of the nearest DatabaseProvider in a component. It throws without one.
DatabaseSession
One open connection.
const session = client.session(connection);
const info = await session.server();
const tables = await session.tables('main');
const page = await session.rows('main', 'customers', { offset: 0, limit: 100, where: "country = 'NL'", orderBy: 'name' });| Method | Resolves with |
|---|---|
server(options?) | ServerInfo: the flavor and version. Opens the connection if it is not open. |
schemas(options?) | readonly SchemaInfo[] |
tables(schema, options?) | readonly TableInfo[] |
structure(schema, table, options?) | TableStructure |
rows(schema, table, query, options?) | RowsResult for a RowsQuery |
count(schema, table, where?, options?) | The number of rows, as a number |
cell(schema, table, key, column, options?) | The whole Value of one cell |
apply(schema, table, changes, options?) | The number of rows affected |
execute(sql, options?) | ExecuteResult: the results of the statements and inTransaction |
page(sql, query, options?) | RowsResult for a PageQuery |
transaction(action, options?) | Whether a transaction is open after 'begin', 'commit' or 'rollback' |
export(request, options?) | ExportResult: rows, bytes and elapsedMs |
import(schema, table, request, options?) | The number of rows inserted, for an ImportRequest |
close() | Closes the session |
session.connection is the Connection it was made for.
RowsQuery is { offset, limit, where?, orderBy?, cellLimit? }. PageQuery is { offset, limit, schema?, cellLimit? }. TableRef names a table in a connection by connectionId, schema and table, for code that passes tables around. Every method but close takes RequestOptions last, which is { signal? }. ExecuteOptions adds schema, limit and cellLimit to those.
page fetches one page of one statement that reads, for a result larger than the first page execute returns; see the protocol. transaction starts or ends a transaction the person drives, and execute reports the state afterwards in inTransaction; see Transactions.
An ExportRequest is { source, format, path, header?, tableName? } with an ExportSource and a FileFormat, and an ImportRequest is { path, format, header, columns }. Both are described in Files.
const { rows } = await session.export({
source: { kind: 'query', sql: 'SELECT * FROM orders', schema: 'main' },
format: 'csv',
path: '/Users/me/Downloads/orders.csv'
});Retry rules
The host can lose a session without the page knowing: the helper exited, or crashed, and its sessions went with it. The next request then fails with unknown-session. The session handles that, but only where it is safe:
- A read (
schemas,tables,structure,rows,count,cell,page) opens the connection again and is sent once more. Sending a read twice does no harm. - A write (
apply,import,export) andexecuteandtransactionare not sent again. The session forgets the lost connection, so the next call opens a new one, and this call fails withunknown-session. Nobody can tell whether a write ran before the helper died. - A second
unknown-sessionon the retry of a read is passed on.
Concurrent calls on a closed session share one open. If it fails, the next call tries again.
Schema changes
A tree that lists tables, or a view that shows a structure, goes stale when something creates, alters or drops. The client keeps the listeners that want to hear about it.
const stop = client.onSchemaChange((change) => {
if (change.connectionId === connection.id) {
reloadTables(change.schema);
}
});A SchemaChange is a connectionId and a schema. schema is undefined when the statement did not say which schema, and a listener then reloads every schema of the connection.
The client announces a change itself after an execute in which a statement succeeded that starts with CREATE, ALTER, DROP, RENAME or TRUNCATE (comments before it do not count). A view that changes the shape another way calls client.notifySchemaChange({ connectionId, schema }). A listener that throws is logged and does not keep the others from hearing the change. DatabaseExplorer, StructureView, TableView and TableDesigner listen.
Abort and cancel
Every method takes an AbortSignal through its options. Aborting does two things: the call rejects at once with a DatabaseRequestError with code cancelled, and the client sends a cancel request for it, so the server stops the work. A signal that is aborted before the call rejects without sending anything.
const controller = new AbortController();
const counting = session.count('main', 'orders', "status = 'pending'", { signal: controller.signal });
controller.abort();
await counting.catch((error) => error instanceof DatabaseRequestError && error.code === 'cancelled');The host only cancels requests of the owner that sent them.
Errors
A request that fails rejects with a DatabaseRequestError, an Error with the code of the protocol.
| Field | |
|---|---|
code | A DatabaseErrorCode, such as query-failed or conflict. |
message | The sentence from the server, the helper or the host. |
sqlState | The five characters of SQLSTATE, or undefined. |
change | For conflict, which change of an apply failed, counted from zero. Otherwise undefined. |
try {
await session.apply('main', 'orders', [{ kind: 'update', key: { id: 3 }, values: { status: 'shipped' } }]);
} catch (error) {
if (error instanceof DatabaseRequestError && error.code === 'conflict') {
showMessage(`Change ${error.change! + 1} matched no row or more than one.`);
}
}The codes are listed in the protocol. A transport that rejects, or resolves with something that is not a response, becomes helper-unavailable.
Closing
session.close() and client.disconnect(id) close the connection on the server and forget it here. They never reject. client.dispose() does that for every session, for when a page goes away.