KineDB Labs

@kinedb/client (0.0.0-g323c099f730a)

Published 2026-10-27 08:16:49 +00:00 by skadefro

Installation

@kinedb:registry=https://git.kinedb.com/api/packages/kinedb/npm/
npm install @kinedb/client@0.0.0-g323c099f730a
"@kinedb/client": "0.0.0-g323c099f730a"

About this package

The direct client of kinedb — JSON over HTTP, binary TLV over WebSocket.

@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,
// }
  • columns holds the column names, in the order of the SELECT.
  • data holds one array for each row, aligned to columns. SQL NULL is null. Over HTTP and over a socket, an Int past 2^53 is an exact bigint. A smaller Int is a number.
  • count is the number of rows. A query with no rows gives data: [] and count: 0.
  • types holds one {code, name, param} for each column. name is Null, Bool, Int, Float, Text, Bytes, Timestamp, Json, Blob, Vector or Array.
  • schemaId names the column set, as 16 lowercase hex digits.
  • tableVersions holds a [table, version] pair for each table that the query read. See the schema version of a table.
  • elapsed_us is the time of the server for the request, in microseconds. It is absent when the reply does not carry it.
  • types, schemaId and tableVersions come only over a socket. The reply of sql() over HTTP has type, columns, data, count and elapsed_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) gives R[].
  • The values do not change. A cell keeps the type that sql() gave it, for example a bigint for an INT past 2^53.
  • A result that is not rows answers []: created, auth_result, null and undefined too. So it is safe on whatever sql() returned. A rows result 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. keySignerFromPkcs8 imports it with WebCrypto as a NOT extractable CryptoKey. Only the closure of signer.sign holds it.
  • signer.keyId is 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>}. sign returns the raw r || s signature (64 bytes) of SHA-256 over bytes. 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 authenticateWithKey again with the same signer.
  • Errors: a wrong key and an unknown user both get the server text invalid username or password. An old server gives SERVER_TOO_OLD_FOR_KEY_LOGIN. A signer that throws gives KEY_DID_NOT_SIGN (the key did not sign); the signer's own error is in err.cause only.
  • console.log(err) and similar tools print err.cause, which can hold the error text of the signer. Do not log the error object to a shared sink. Log err.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 as authenticateWithKey, and sends the proof to POST /login. The function calls signer.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 also err.retryAfterSec). Call loginWithKey again 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 no POST /login/challenge) and KEY_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_version is an integer. The value 0 means unknown.
  • unique is true for one column. A UNIQUE index of ONE column sets it. A composite unique index does not set it.
  • default is 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 DESCRIBE reads show two versions, the call starts again. After 3 reads, it throws a KineDBError with the words schema changed.
  • A name that is not a plain identifier goes in brackets. describe('my table') sends DESCRIBE [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 FUNCTIONS one time and keeps every signature. An unknown name gives one more read; a name that is still unknown throws a KineDBError, and no CALL goes out. A function with no parameter types (made before B2172) throws a KineDBError that tells you to replace it with CREATE OR REPLACE FUNCTION.
  • The signature sets the type, not the JavaScript value. 2 into a FLOAT parameter goes as a FLOAT. A plain object into a ROW parameter goes as JSON. A TIMESTAMP takes a Date, an integer of microseconds or an ISO 8601 text with a Z. A VECTOR takes a Float32Array or an array of numbers, and the dimension is checked. A value that does not fit throws a TypeError or a RangeError that 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 reads SHOW FUNCTIONS again and sends one more time. A second refusal is the answer. The retry option 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 of sql().
  • No transaction stays open on the server. BEGIN, COMMIT and ROLLBACK are not statements of a transaction, and sql() with one of them is refused. Two transaction() calls on one socket are two separate transactions.
  • The answer is created, as for a write with sql(). When a statement fails, the server writes nothing and the call throws a KineDBError.
  • A SELECT inside a transaction gives no rows. For a read-modify-write, put the read and the write in a function and run it with CALL (see the SQL page, § Functions and tickers).
  • token is the commit token. The server stores it in the same commit as the rows. A client that lost its connection asks GET 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 retry option has the shape of the constructor option and wins over it (retry: false turns it off). A retry sends the WHOLE transaction again, never a part of it. db and branch win over the default of the client. Over HTTP a branch other than main is refused, as for sql(): use the socket.
  • The transaction holds no lock. At the commit the server checks each row that an UPDATE, a DELETE or an INSERT over an existing key started from. When another client wrote that row first, the server writes nothing and answers calvin conflict: … — nothing applied, retry, with the retry flag set. An INSERT of a new key is not checked: when two transactions insert the same key, the later write wins. To claim a key, use INSERT … IF NOT EXISTS, also inside a transaction: a key that exists refuses the whole transaction with duplicate key:. A blind UPDATE (WHERE <primary key> = <value>, only literal SETs, 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 is 0x1E.

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 only error creates of one model is ONE INSERT … IF NOT EXISTS, and a present key refuses all of it. A key that the ORM generates (a text key with no value) is a plain INSERT. A key that you give, with other changes in the same block, refuses with unsupported (B3168): the ORM does not send IF NOT EXISTS inside a transaction yet. The engine accepts it there since B3168.
  • upsert replaces the whole row at its key. A model with a counter refuses it (counter_upsert).
  • ignore refuses with unsupported (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 in tx.create() or a filter.
  • onDelete is none (the default), nullify (an optional ref only) or cascade. The ORM writes the rules in the same transaction as the delete.
  • A manyToMany with no through declares the link table <source table>_<field> (tasks_tags); the other side names it with inverse. With through: () => Link, a model of yours is the link. The key of a link row is <k1>:<k2>, so two add() calls of one pair give one row.
  • where() on a many-to-many result refuses with unsupported (B3289). A link model with another field refuses add() (B3262): create the link with tx.create(Link, values). The spec is docs/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.

Details
npm
2026-10-27 08:16:49 +00:00
1
UNLICENSED
g323c099f730a
170 KiB
Assets (1)
Versions (11) View all