@kinedb/client (0.0.0-g791e73859dc9)
Installation
@kinedb:registry=https://git.kinedb.com/api/packages/kinedb/npm/npm install @kinedb/client@0.0.0-g791e73859dc9"@kinedb/client": "0.0.0-g791e73859dc9"About this package
@kinedb/client
The direct client of kinedb: JSON over HTTP, binary TLV over WebSocket. It
speaks to a running kinedb-server.
This package used to be the file web/src/data/kinedb.js inside the console app,
and every other project copied it by hand (B836).
The two shapes of every kinedb SDK
kinedb ships each SDK twice, in every language. The names are fixed, and the API surface is identical, so an application changes ONE import line and nothing else.
| role | Rust crate | npm | status |
|---|---|---|---|
| wire types + TLV/frame codec | kinedb-protocol |
@kinedb/protocol |
the column-code table only |
| direct client (remote server) | (none yet) | @kinedb/client |
this package |
| embedded engine + sync | kinedb |
@kinedb/embedded |
built (B1150) |
import { KineDB } from '@kinedb/client'; // talks to a remote kinedb-server
import { KineDB } from '@kinedb/embedded'; // talks to a local engine that syncs
@kinedb/embedded bundles the kinedb engine as WebAssembly (B1150). The SDK
talks to that local embedded database, and a query answers the SAME structured
result this package answers on its typed path — the same columns, the same type
codes and the same JavaScript values. So an application changes the one import
line and reads its rows unchanged.
THE CONNECTION HALF IS BUILT, THE SYNC HALF IS NOT. @kinedb/embedded now
connects to a full kinedb-server, logs in, runs statements and watches tables:
await db.connect(url, {user, password}) answers a connection with sql,
watch, reconnect and close, and a statement over it reads exactly as a
local one does. B1146 (#1231) brought the wire protocol to wasm32 and B1162
(#1259) wrapped it.
What is left is the SYNC. kinedb-sync makes an embedded database a read cache
that answers its reads offline (B1148), and that crate works in Rust today. The
JavaScript wrapper over it is B1194 (#1307), so @kinedb/embedded's sync()
still throws and names that slice.
Install
The packages live on the forge, under the kinedb organisation:
https://git.kinedb.com/kinedb/-/packages. There is no npmjs.com copy.
.npmrc:
@kinedb:registry=https://git.kinedb.com/api/packages/kinedb/npm/
The registry needs no token: the kinedb organisation is public. A token without the
package scope gets 401 Unauthorized. To send a token, add the line
//git.kinedb.com/api/packages/kinedb/npm/:_authToken=<gitea token with package read scope>.
Tags are commits, like docker — not version numbers
kinedb has not released an SDK, so no package carries a version number that promises compatibility. A build is named by the commit that produced it:
npm install @kinedb/client@a1b2c3d # the short commit sha
npm install @kinedb/client@pr-740 # the head of a pull request
Nothing is published to latest. The version field reads 0.0.0-g<short-sha>
only because npm refuses a manifest without a semver string; the sha in the
prerelease slot is the real identity. Read the tag, ignore the number.
Publishing: bun scripts/sdk-publish.ts sdk/js/client from the repo root. It
refuses a dirty tree, because a hash tag must name a commit that actually holds
the published bytes.
Use
import { KineDB } from '@kinedb/client';
const db = new KineDB('http://localhost:4820');
const r = await db.sql('SELECT 1'); // one-shot over HTTP
const h = await db.health();
const ws = await db.connect(); // a persistent WebSocket
const rows = await ws.sql('SELECT * FROM users');
const sub = await ws.watch('users', (n) => console.log(n));
sub.cancel();
// A filter value goes in `params`, never in the SQL text (B3111).
const mine = await ws.watch('orders', (n) => console.log(n),
{ where: 'owner = $1', params: ['alice'] });
mine.cancel();
ws.close();
With no argument the client derives its URLs from the page (defaultBaseUrl(),
defaultWsUrl()), which is what a browser app behind a reverse proxy wants.
The result of sql()
sql() resolves with one object. Its type names the kind of result. An error
reply does not resolve: the client throws a KineDBError, and err.response
holds the reply. A SELECT gives a rows result:
const r = await ws.sql('SELECT id, name FROM people ORDER BY id');
// The shape of the result; the values differ in your database.
// {
// type: 'rows',
// columns: ['id', 'name'],
// data: [[1, 'ada'], [2, null]],
// count: 2,
// types: [{ code: 2, name: 'Int', param: 0 }, { code: 4, name: 'Text', param: 0 }],
// schemaId: '1729a716969e9291',
// tableVersions: [['people', 11]],
// elapsed_us: 2530,
// }
columnsholds the column names, in the order of theSELECT.dataholds one array for each row, aligned tocolumns. SQLNULLisnull. Over HTTP and over a socket, an Int past 2^53 is an exactbigint. A smaller Int is anumber.countis the number of rows. A query with no rows givesdata: []andcount: 0.typesholds one{code, name, param}for each column.nameisNull,Bool,Int,Float,Text,Bytes,Timestamp,Json,Blob,VectororArray.schemaIdnames the column set, as 16 lowercase hex digits.tableVersionsholds a[table, version]pair for each table that the query read. See the schema version of a table.elapsed_usis the time of the server for the request, in microseconds. It is absent when the reply does not carry it.types,schemaIdandtableVersionscome only over a socket. The reply ofsql()over HTTP hastype,columns,data,countandelapsed_us.
The other statements give a result with no rows:
type |
from | fields |
|---|---|---|
'created' |
a DDL statement, for example CREATE TABLE, DROP TABLE, ALTER TABLE, CREATE INDEX, DROP INDEX or USE |
none |
'inserted' |
INSERT |
count: the rows that the statement inserted |
'updated' |
UPDATE |
count: the rows that the statement updated |
'deleted' |
DELETE |
count: the rows that the statement deleted |
'watching' |
WATCH <table> |
table |
'unwatched' |
UNWATCH |
count: the subscriptions that the statement ended |
'backoff' |
the server is busy | retry_ms: the wait that the server asks for |
await ws.sql("INSERT INTO people (id, name) VALUES (3, 'cy')");
// The shape of the result; the values differ in your database.
// { type: 'inserted', count: 1, elapsed_us: 63 }
A statement on users, roles and grants gives a rows result with one
result column of text. For example CREATE USER, CREATE ROLE and GRANT:
await ws.sql("GRANT SELECT ON people TO ROLE readers");
// The shape of the result; the values differ in your database.
// { type: 'rows', columns: ['result'], data: [["granted on 'people'"]], count: 1, … }
A socket sends the request again after a 'backoff' by itself. sql() over
HTTP gives the 'backoff' result to you, unless the retry option allows the
retry (see Retrying). In TypeScript the result is
SqlResponse<T>: switch on type to get the fields of each kind. To read the
rows as objects, use rowsAsObjects (below). The README of @kinedb/embedded
has a table of the JavaScript value of a cell for each type code.
Rows as objects
A rows result holds the column names in columns and each row as an array of
values in data. rowsAsObjects(result) turns the rows into objects, one key for
each column:
import { rowsAsObjects } from '@kinedb/client';
const r = await ws.sql('SELECT id, name FROM people');
// { type: 'rows', columns: ['id', 'name'], data: [[1, 'ada'], [2, null]], … }
rowsAsObjects(r); // [{ id: 1, name: 'ada' }, { id: 2, name: null }]
- The signature is
rowsAsObjects(result) → object[]. In TypeScript,rowsAsObjects<R>(result)givesR[]. - The values do not change. A cell keeps the type that
sql()gave it, for example abigintfor an INT past 2^53. - A result that is not
rowsanswers[]:created,auth_result,nullandundefinedtoo. So it is safe on whateversql()returned. Arowsresult with no rows also answers[]. - Two columns with one name give one key. The value of the later column
wins. Give the columns of a join different names with
AS.
@kinedb/embedded exports the same function.
Credentials
The client holds no session. An application installs two hooks once, and every
new KineDB(...) in that application picks them up:
import { setAuthHooks } from '@kinedb/client';
setAuthHooks({
getToken: () => myStore.token, // read on EVERY request, never cached
// runs on a 401, BEFORE the throw; receives the token that request sent
onUnauthorized: (refused) => { if (refused === myStore.token) myStore.logout(); },
});
onUnauthorized receives the bearer of the refused request (null when it sent
none). A 401 can arrive after a new login, for a request sent with the old token;
compare, so that the late refusal does not log the new session out.
One client can override them:
new KineDB(url, undefined, { getToken: () => otherToken });
With neither, the client sends no bearer and bounces nobody — the right default
for a consumer that never logs in. GET /health stays bare in every case,
because it is open by design and a liveness probe has no credentials.
setAuthHooks returns the hooks it replaced, so a test or a one-off task can put
them back.
Login with an API secret
A WebSocket connection logs in with one of four calls. Each call returns
{type:'auth_result', username, is_superuser}.
await sock.authenticate('root', password);
await sock.authenticateWithToken(token);
await sock.authenticateWithApiSecret('root', process.env.KINEDB_API_SECRET); // kdsk_...
await sock.authenticateWithKey('alice', signer); // see "Log in with a key pair"
The API-secret login names the user and sends the secret in the login frame. The secret must be a non-empty string. No error message contains the secret.
Every login carries CLIENT_PROTOCOL_VERSION. The version is 6 since the token query
(QueryAs, B2843). A server refuses a client of a lower version than its minimum with
an "upgrade the SDK" error, so upgrade the SDK and the server together.
queryAs
sock.queryAs(token, sql, { db, branch, params, timeoutMs }) runs ONE statement with
the rights of the user of the bearer token token (kdt1_…), on this shared socket
(QueryAs, B2886). The user of the socket must hold GRANT QUERY AS. A refusal keeps
the server text; the package exports each text (SESSION_REJECTED,
QUERY_AS_NO_SUBSCRIPTION, QUERY_AS_NO_USE, QUERY_AS_NO_TXN_CONTROL,
QUERY_AS_CLIENT_CONNECTION_ONLY, SERVER_TOO_OLD_FOR_QUERY_AS). The client does not
retry queryAs. With no reply within timeoutMs, it closes the socket. No error text
contains the token.
Log in with a key pair
A user with a public key (ALTER USER alice ADD PUBLIC KEY '<point or PEM>') logs
in with the private key. The private key never goes to the server.
import { readFileSync } from 'node:fs';
import { keySignerFromPkcs8 } from '@kinedb/client';
// The .pk8.pem file of the web console, or the PKCS#8 DER bytes.
const signer = await keySignerFromPkcs8(readFileSync('alice.pk8.pem', 'utf8'));
await sock.authenticateWithKey('alice', signer, { clientName: 'my-app' });
- The key is ECDSA P-256.
keySignerFromPkcs8imports it with WebCrypto as a NOT extractableCryptoKey. Only the closure ofsigner.signholds it. signer.keyIdis the lower-case hex SHA-256 of the 65-byte public point (0x04 || x || y), not of the SPKI.- A custom signer is
{keyId, sign(bytes) → Promise<Uint8Array>}.signreturns the rawr || ssignature (64 bytes) of SHA-256 overbytes. A signature of another length is refused, and the client sends no login frame. - Each call asks the server for a new challenge with a new random nonce. A
signature is never sent twice. The client does not retry a refused login.
After a reconnect, call
authenticateWithKeyagain with the same signer. - Errors: a wrong key and an unknown user both get the server text
invalid username or password. An old server givesSERVER_TOO_OLD_FOR_KEY_LOGIN. A signer that throws givesKEY_DID_NOT_SIGN(the key did not sign); the signer's own error is inerr.causeonly. console.log(err)and similar tools printerr.cause, which can hold the error text of the signer. Do not log the error object to a shared sink. Logerr.message.- No error text contains a nonce, a signature or key bytes.
Get a bearer token with a key pair (HTTP)
loginWithKey logs in over HTTP and answers a bearer token (B3671). It needs no socket.
import { KineDB, keySignerFromPkcs8, loginWithKey } from '@kinedb/client';
const signer = await keySignerFromPkcs8(readFileSync('alice.pk8.pem', 'utf8'));
const { token, expires_at_ms } = await loginWithKey('https://db.example.com', 'alice', signer,
{ clientName: 'my-app', timeoutMs: 10_000 });
const db = new KineDB('https://db.example.com', undefined, { getToken: () => token });
- One call sends
POST /login/challenge, signs the same bytes asauthenticateWithKey, and sends the proof toPOST /login. The function callssigner.sign(bytes)only: it never sees the private key, and it sends the key id, the signature and the nonces. - Each call asks for a new challenge with a new random nonce. A proof is never sent
twice. The function does not retry: a 503 or a 429 gives
err.retryable(a 429 alsoerr.retryAfterSec). CallloginWithKeyagain to retry with a new challenge. timeoutMs(default 10 s) bounds each of the two requests. A timeout is retryable.- The errors are those of
authenticateWithKey:invalid username or password,SERVER_TOO_OLD_FOR_KEY_LOGIN(the server has noPOST /login/challenge) andKEY_DID_NOT_SIGN. No error text contains the signature, a nonce, the challenge or the token.
Retrying
With no retry option, the client retries ONE kind of error: a typed hold
refusal (B2298). Every other server-flagged transient rejection throws at once,
with err.retryable set, so a caller can build its own loop.
A hold refusal has refusal.kind write_hold or not_ready (B495). A split, a
merge or a leadership transfer holds the writes of a shard for a short time, and
the server refuses a write with refusal: {kind: 'write_hold', retry_ms}. The
server sends the refusal before the write takes effect, so nothing was written
and a resend is safe. The client sends the request again after retry_ms until
holdBudgetMs (20 s, HOLD_RETRY_BUDGET_MS) from the first send. After the
budget, the client throws the last refusal. This is the default for sql(),
transaction(), a socket from connect() and putFile(). For a file, the
client sends again only the refused chunk frame or manifest frame, never the
whole upload.
retry: false turns off every retry, the hold retry too:
const db = new KineDB(url, undefined, { retry: false });
retry: true also retries the other transient rejections. The client waits a
random full-jitter time between two attempts, and honours the server's own
retry_ms pacing hint on a backoff response. A hold refusal does not count
against maxRetries. Every other retryable error keeps maxRetries.
const db = new KineDB(url, undefined, { retry: true });
const db2 = new KineDB(url, undefined, { retry: { maxRetries: 5, holdBudgetMs: 20000 } });
The outcome is unknown
A KineDBOutcomeUnknownError (a subclass of KineDBError) tells you that the
server sent your request on, but the reply was lost (B2304). The write MAY have
applied. The error has outcomeUnknown true, retryable false and
response.refusal.kind outcome_unknown (TLV kind code 5). No retry option
sends the request again, also not retry: true. Read the data back before you
send the request again:
import { KineDBOutcomeUnknownError } from '@kinedb/client';
try {
await db.sql("INSERT INTO orders VALUES (42, 'paid')");
} catch (err) {
if (err instanceof KineDBOutcomeUnknownError) {
const r = await db.sql('SELECT id FROM orders WHERE id = 42');
// No row: the insert did not apply, and you can send it again.
}
}
A default database
const db = new KineDB(url, undefined, { database: 'app' }); // branch main
const dev = new KineDB(url, undefined, { database: 'app', branch: 'dev' });
ws.setContext({ db: 'app', branch: 'dev' }); // one socket; setContext() removes it
Every statement of a socket carries the default. db.sql() sends ?db=, and it
throws a KineDBError for a default branch other than main: POST /sql has no
branch selector. A qualified table name (default.t) wins over the default. A
socket with a default refuses USE.
Parameters
sql(text, params) sends the values of $1, $2, … apart from the text. The
server binds them, so a value is never parsed as SQL. Use parameters for every
value that comes from a user.
const name = "it's'); DELETE FROM t; --";
await ws.sql('INSERT INTO t (id, name) VALUES ($1, $2)', [2, name]); // one row, a string
await ws.sql('SELECT id FROM t WHERE name = $1', [name]);
await db.sql('SELECT id FROM t WHERE id = $1', [2]); // HTTP too
| JavaScript value | SQL type |
|---|---|
an integer number, a bigint |
INT |
a fractional number |
FLOAT |
string |
TEXT |
boolean |
BOOL |
Date |
TIMESTAMP (microseconds) |
Uint8Array (a Buffer too) |
BYTES |
Float32Array |
VECTOR (its length is the dimension) |
null, undefined |
NULL |
The client throws a TypeError or a RangeError before it sends anything for:
a plain object, an array, any other typed array, NaN or ±Infinity, an
invalid Date, a bigint outside the i64 range, and an integer number
outside ±(2^53-1) (pass a bigint). No params (or []) sends the old bytes.
Over HTTP the body is the TLV [sql, P] with the content type
application/vnd.kinedb.sql-params.
Describe a table
describe(table) answers the schema of one table: the columns, the primary key
and the indexes. It runs DESCRIBE, SHOW INDEXES FROM and DESCRIBE again
through sql(), so it uses the database and the branch of the connection.
const t = await db.describe('users');
// { table: 'users', schema_version: 42, primary_key: ['id'],
// columns: [{ name: 'email', type: 'Text', not_null: true, default: null,
// primary_key: false, unique: true, indexes: ['users_email'] }, …],
// indexes: [{ name: 'users_email', columns: ['email'], unique: true }, …] }
- The names are snake_case.
schema_versionis an integer. The value 0 means unknown. uniqueis true for one column. A UNIQUE index of ONE column sets it. A composite unique index does not set it.defaultis SQL text. The text'it''s'is a default with a quote. The value is null when the column has no default.- The schema can change during the read. When the two
DESCRIBEreads show two versions, the call starts again. After 3 reads, it throws aKineDBErrorwith the wordsschema changed. - A name that is not a plain identifier goes in brackets.
describe('my table')sendsDESCRIBE [my table].
describe() is on KineDB (HTTP) and on a socket from connect().
Call a function
call(name, args) calls a function with typed arguments, on KineDB (HTTP)
and on a socket from connect(). args is an array in signature order, or an
object by parameter name. A missing NULL parameter is null.
await ws.call('add_points', [1, 50]); // 51
await ws.call('add_points', { player: 1, points: 50 });
await db.call('save_profile', [{ name: 'Ann' }]); // a ROW goes as JSON
await ws.call('top_players', [10]); // RETURNS TABLE → [{id, name}, …]
- The signature is read one time. Each client object reads
SHOW FUNCTIONSone time and keeps every signature. An unknown name gives one more read; a name that is still unknown throws aKineDBError, and no CALL goes out. A function with no parameter types (made before B2172) throws aKineDBErrorthat tells you to replace it withCREATE OR REPLACE FUNCTION. - The signature sets the type, not the JavaScript value.
2into a FLOAT parameter goes as a FLOAT. A plain object into a ROW parameter goes as JSON. A TIMESTAMP takes aDate, an integer of microseconds or an ISO 8601 text with aZ. A VECTOR takes aFloat32Arrayor an array of numbers, and the dimension is checked. A value that does not fit throws aTypeErroror aRangeErrorthat names the parameter, before anything is sent. - The statement is
CALL name($1, …, $n)with typed parameters, so a value is never parsed as SQL. - A stale signature is read again one time. After
CREATE OR REPLACE FUNCTION, the server refuses a call with the old types (refusal.kind === 'signature').call()then readsSHOW FUNCTIONSagain and sends one more time. A second refusal is the answer. Theretryoption never re-sends this refusal. An argument list that does not fit the CACHED signature (for example, a new parameter) also gives one new read before the client refuses it. - The result:
RETURNS <type>gives the one value (a ROW is an object);RETURNS TABLE (…)gives the rows as objects.
Transactions
transaction(statements, options) sends ONE complete transaction in ONE
request. The server writes all the statements in one atomic write, or it writes
none of them. KineDB (POST /sql) and KineDBSocket have the same method:
await db.transaction([
['INSERT INTO orders (id, item) VALUES ($1, $2)', [1, 'pen']],
['INSERT INTO orders (id, item) VALUES ($1, $2)', [2, 'ink']],
'DELETE FROM carts WHERE id = 7',
], { token: 'order-1-2' }); // both rows and the delete are written, or none
- A statement is a string,
[sql]or[sql, params]. The params are the values of$1,$2, …, with the type table ofsql(). - No transaction stays open on the server.
BEGIN,COMMITandROLLBACKare not statements of a transaction, andsql()with one of them is refused. Twotransaction()calls on one socket are two separate transactions. - The answer is
created, as for a write withsql(). When a statement fails, the server writes nothing and the call throws aKineDBError. - A
SELECTinside a transaction gives no rows. For a read-modify-write, put the read and the write in a function and run it withCALL(see the SQL page, § Functions and tickers). tokenis the commit token. The server stores it in the same commit as the rows. A client that lost its connection asksGET TRANSACTION STATUS '<token>'if the commit happened, and it sends the transaction again only when it did not. A failed transaction stores no token.- The
retryoption has the shape of the constructor option and wins over it (retry: falseturns it off). A retry sends the WHOLE transaction again, never a part of it.dbandbranchwin over the default of the client. Over HTTP a branch other thanmainis refused, as forsql(): use the socket. - The transaction holds no lock. At the commit the server checks each row that
an
UPDATE, aDELETEor anINSERTover an existing key started from. When another client wrote that row first, the server writes nothing and answerscalvin conflict: … — nothing applied, retry, with the retry flag set. AnINSERTof a new key is not checked: when two transactions insert the same key, the later write wins. To claim a key, useINSERT … IF NOT EXISTS, also inside a transaction: a key that exists refuses the whole transaction withduplicate key:. A blindUPDATE(WHERE <primary key> = <value>, only literalSETs, a table with no index and no policy, and no read of that row earlier in the transaction) is not checked either: it writes only its columns, and the later write of a column wins. - The client puts consecutive statements with the same text and the same
parameter types in one group on the wire. The order of the statements never
changes. Over HTTP the body is the TLV payload with the content type
application/vnd.kinedb.sql-txn; over the socket the frame type is0x1E.
The SQL page, § Transactions, has the full rules.
The ORM: @kinedb/client/orm
The package carries the ORM of kinedb (B2387, phase P2 of B3099). A model is a
class that maps to ONE table. The ORM creates the table, reads rows as objects,
and writes the changes of a write block as ONE transaction. The contract is
docs/orm/spec.md.
import { Credentials, Database, Model, field, table } from '@kinedb/client/orm';
@table('tasks')
class Task extends Model {
@field.text({ primaryKey: true }) accessor id!: string;
@field.text({ default: 'untitled' }) accessor title!: string;
@field.bool({ default: false }) accessor done!: boolean;
@field.int({ optional: true, indexed: true }) accessor priority!: number | null;
@field.timestamp({ optional: true }) accessor due!: Date | null;
@field.int({ counter: true, default: 0 }) accessor views!: number;
}
const db = await Database.open({
serverUrl: 'https://db.example.com',
credentials: Credentials.usernamePassword({ username: 'ana', password: '…' }),
schema: [Task],
database: 'app', // optional
});
const open = await db.objects(Task)
.where({ done: false, priority: { gte: 2 } })
.orderBy(['-priority', 'due'])
.limit(50)
.ready;
await db.write((tx) => {
open[0].done = true;
open[0].views += 1;
tx.create(Task, { title: 'Write docs', priority: 3 });
});
The models. TypeScript declares a model with TC39 standard decorators
(TypeScript 5.2 or later, without experimentalDecorators). Each field is an
accessor. The field types are text, int, int64 (a bigint), float,
bool, bytes (a Uint8Array), timestamp (a Date) and vector(n) (a
Float32Array). The options are primaryKey, optional, default, unique,
indexed, counter, column and renamedFrom. Plain JavaScript has no
decorator syntax in Node, so defineModel({table, fields: [...]}) takes the
same model as data. A ref, a backlink and a manyToMany field relate two
models (P5, B3102; see "References and relations").
The open. Database.open() compares each model with its table (code
first). A table that does not exist gets CREATE TABLE and its indexes. A
table that exists gets the statements of the difference: a rename with
renamedFrom, a new optional column, a type change, an index. The ORM checks
and plans EVERY model before it runs a statement. A refused model, a blocked
difference (a new required field, a key change, a type that is not scalar) or a
destructive statement (a column that no field names) refuses the open, and
nothing runs. The error is an OrmError with a code, the table, and the
field or the column. A difference of NOT NULL or DEFAULT is a notice in
db.notices.
The queries. db.objects(Model) gives a result. where(), orderBy() and
limit() each give a new result; await result.ready runs the query. The
result is live (P3, B3100): its list changes when the rows change (see "Live
results"). A filter value is always a $n parameter of the type of its
field. The ORM adds the primary key as the last sort key.
db.objectForKey(Model, key) gives one object, or null.
Live objects. Each object that the ORM gives is live (P3, B3100): when another client changes its row, the fields of the object change too.
const task = await db.objectForKey(Task, 'a');
const stop = task.subscribe((change) => {
// {deleted: false, changedFields: ['title']}, or {deleted: true, changedFields: []}
});
for await (const change of task.changes()) { /* the same events; it ends after deleted */ }
stop();
An update with the same values gives no event. A delete, or a row that you may
no longer read, makes the object invalid (task.isValid is false), and then
no more events come. The ORM keeps ONE watch for each table on the socket.
Live results. A result is ONE predicate of the watch of its table. Its list changes in place, and each change gives ONE event with the indices of the list:
const open = await db.objects(Task).where({ done: false }).orderBy('-priority').limit(20).ready;
const stop = open.subscribe((change) => {
// {deletions: [0], insertions: [3], modifications: [1], moves: [{from: 4, to: 2}], reset: false}
});
for await (const change of open.changes()) { /* the same events */ }
stop();
Replay an event on a UI list: delete at deletions and moves[].from (old
indices), insert at insertions and moves[].to (new indices), then reload
modifications. A gap of the watch gives ONE event with reset: true: reload
the whole list. A result with a limit watches about 2 × limit rows and moves
its bound when the window gets short, with no short list in between. A result
shows the commits of this database that came before it. close() makes the
result invalid (isValid false). The spec is docs/orm/spec.md § 7.
The write block. The callback of db.write() runs synchronously and only
records. An assignment outside a write block throws. When the callback returns,
the ORM sends the changes as ONE transaction: an update writes only the changed
columns, and a counter writes a delta (SET [views] = [views] + $1). A throw or
a refusal writes nothing, and each object goes back to its last values. The
modes of tx.create(Model, values, mode):
error(the default) never replaces a row. A block of onlyerrorcreates of one model is ONEINSERT … IF NOT EXISTS, and a present key refuses all of it. A key that the ORM generates (atextkey with no value) is a plainINSERT. A key that you give, with other changes in the same block, refuses withunsupported(B3168): the ORM does not sendIF NOT EXISTSinside a transaction yet. The engine accepts it there since B3168.upsertreplaces the whole row at its key. A model with a counter refuses it (counter_upsert).ignorerefuses withunsupported(B3168).
References and relations (P5, B3102). A ref is a column with the key of
the target row. A backlink and a manyToMany are live results, not columns.
import { Model, field, refKey, table } from '@kinedb/client/orm';
import type { ManyToManyResults, Results } from '@kinedb/client/orm';
@table('projects')
class Project extends Model {
@field.text({ primaryKey: true }) accessor id!: string;
@field.backlink(() => Task, 'project') accessor tasks!: Results<Task>;
}
@table('tasks')
class Task extends Model {
@field.text({ primaryKey: true }) accessor id!: string;
@field.ref(() => Project, { onDelete: 'cascade' }) accessor project!: Project;
@field.manyToMany(() => Tag) accessor tags!: ManyToManyResults<Tag>;
}
@table('tags')
class Tag extends Model {
@field.text({ primaryKey: true }) accessor id!: string;
@field.manyToMany(() => Task, { inverse: 'tags' }) accessor tasks!: ManyToManyResults<Task>;
}
const db = await Database.open({ serverUrl, credentials, schema: [Project, Task, Tag] });
const task = await db.objectForKey(Task, 't1');
task.project; // the Project object, or null (absent, hidden, not arrived yet)
refKey(task, 'project'); // the stored key, always
const tasks = await task.project.tasks.orderBy('id').ready; // a live result
const tags = await task.tags.orderBy('id').limit(10).ready; // a live result of Tag
await db.write(() => {
task.tags.add('urgent'); // an object of Tag or its key
task.tags.remove('later');
});
- A read of a ref gives the target object, or null. When the target arrives,
changes or goes, the object gets ONE event that names the field. A ref takes
an object of the target model or a key; a filter on a ref takes both too.
In TypeScript the accessor has the type of the target model, so an
assignment of a key (
task.project = 'p1') is a type error; JavaScript accepts it. Assign the object, or give the key intx.create()or a filter. onDeleteisnone(the default),nullify(anoptionalref only) orcascade. The ORM writes the rules in the same transaction as the delete.- A
manyToManywith nothroughdeclares the link table<source table>_<field>(tasks_tags); the other side names it withinverse. Withthrough: () => Link, a model of yours is the link. The key of a link row is<k1>:<k2>, so twoadd()calls of one pair give one row. where()on a many-to-many result refuses withunsupported(B3289). A link model with another field refusesadd()(B3262): create the link withtx.create(Link, values). The spec isdocs/orm/spec.md§ 5.
The package. The ORM source tree is sdk/js/orm/ in the repository. The
build of this package (bun run build, src/stage-orm.ts) copies it into
orm/ of the package, so the repository holds one copy.
Test
cd sdk/js/client && bun test src/
The wire format this file mirrors is the Rust one:
crates/kinedb-protocol/src/{frame,tlv,codec,messages}.rs. That is the truth;
this package follows it.