Skip to content

JavaScript ​

kinedb has two JavaScript packages with the same API shape:

  • @kinedb/client sends SQL to a running kinedb server, over HTTP and over a WebSocket.
  • @kinedb/embedded runs the kinedb engine inside your application, with no server.

Both packages export the same KineDB name and answer the same results. So an application moves between them by changing ONE import line:

js
import { KineDB } from '@kinedb/client';     // a remote kinedb-server
import { KineDB } from '@kinedb/embedded';   // a local engine, in this process

A section below says which package it is for. For Python, see Python.

The client: a remote server ​

@kinedb/client is the direct client of kinedb. It sends SQL to a running kinedb server over HTTP (JSON) and over a WebSocket (a binary protocol). It works in a browser, in Bun, and in Node 22 or newer.

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>.

Then install the package:

sh
npm install @kinedb/client

Use ​

js
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:

js
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:

typefromfields
'created'a DDL statement, for example CREATE TABLE, DROP TABLE, ALTER TABLE, CREATE INDEX, DROP INDEX or USEnone
'inserted'INSERTcount: the rows that the statement inserted
'updated'UPDATEcount: the rows that the statement updated
'deleted'DELETEcount: the rows that the statement deleted
'watching'WATCH <table>table
'unwatched'UNWATCHcount: the subscriptions that the statement ended
'backoff'the server is busyretry_ms: the wait that the server asks for
js
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:

js
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:

js
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:

js
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:

js
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}.

js
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.

js
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.

js
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:

js
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.

js
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:

js
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 ​

js
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.

js
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 valueSQL type
an integer number, a bigintINT
a fractional numberFLOAT
stringTEXT
booleanBOOL
DateTIMESTAMP (microseconds)
Uint8Array (a Buffer too)BYTES
Float32ArrayVECTOR (its length is the dimension)
null, undefinedNULL

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.

js
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.

js
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:

js
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.

ts
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.

js
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:

js
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.

ts
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.

Login and the protocol version ​

ws.authenticate(username, password), ws.authenticateWithToken(token), ws.authenticateWithApiSecret(username, secret) and ws.authenticateWithKey(username, signer) send the protocol version of the client (CLIENT_PROTOCOL_VERSION, now 6) in the login frame. The API-secret login (B2499) sends a secret that starts with kdsk_. The login frame has 6 slots. A key login sends 8 slots: the key id is in slot 7 and the signature is in slot 8. It uses the challenge frames before the login frame (see "Log in with a key pair" above). A server refuses a login with no version, or with a version below its minimum. The error names the client version, the server minimum and the fix: "upgrade the SDK".

A key login over HTTP (B3671) uses loginWithKey(baseUrl, username, signer, { clientName, timeoutMs }). It sends POST /login/challenge, signs the same bytes as the WebSocket key login, and sends the proof to POST /login. It answers { token, expires_at_ms }: give the token to getToken of KineDB or to ws.authenticateWithToken(token). The function calls signer.sign(bytes) only and never sees the private key. Each call asks for a new challenge, and the function does not retry. A 503 or a 429 gives err.retryable; call again to get a new challenge.

A login succeeds only on an AuthResult reply. Every other reply rejects the login with a KineDBError unexpected reply to a login: <type> (B2857). The Rust client does the same. The message names the reply type, never a credential.

After a login, every reply has typed cells: rows results and VERBOSE watch rows. The client asks for no typed option at login, and the typedRows option no longer has an effect. A socket with no login asks for typed rows on each statement, so a guest also gets typed cells. A guest reply always carries the full column header.

Parameters ​

Give a value from a caller as a parameter, never as joined SQL text:

js
await ws.sql('SELECT id FROM people WHERE name = $1', [name]);   // the socket
await db.sql('INSERT INTO people (id, name) VALUES ($1, $2)', [7n, name]); // POST /sql

sql(text, params) sends each value with its type on the binary wire. The client picks the type from the value: an integer number or a bigint is INT, a number with a fraction is FLOAT, string is TEXT, boolean is BOOL, Date is TIMESTAMP, Uint8Array is bytes, Float32Array is VECTOR, and null is NULL. An array of scalars of one kind is an ARRAY value (see below). A plain object throws a TypeError. See Parameters.

An array parameter takes booleans, numbers, bigints, strings, Date values or Uint8Array values, and all elements must be of one kind. The array is ARRAY(BOOL), ARRAY(INT), ARRAY(FLOAT), ARRAY(TEXT), ARRAY(TIMESTAMP) or ARRAY(BYTES). When one number has a fraction, the array is ARRAY(FLOAT), else ARRAY(INT). The client throws a TypeError before it sends anything for a null or undefined element, a nested array, an element of another type (for example a Float32Array or an object) and a mix of kinds (a bigint next to a number is a mix). The empty array is valid. A NaN or infinite number throws a RangeError. A column of the type ARRAY(<type>) in a result is a JavaScript array. See Array columns.

Default database ​

A client can name a default database, and a default branch, for its statements:

js
const db = new KineDB('http://localhost:4820', undefined, { database: 'app' });
await db.sql('SELECT * FROM orders');          // POST /sql?db=app
const ws = await db.connect();
await ws.sql('SELECT * FROM orders');          // app.orders on main
ws.setContext({ db: 'app', branch: 'dev' });   // app@dev from now on
ws.setContext();                               // no default
  • A socket sends the default in every statement frame. The server keeps no default for the connection.
  • A default database with no branch uses the branch main. A branch with no database is an error (TypeError).
  • ws.setContext({ db, branch }) changes the default of one socket. With no argument, it removes the default.
  • A qualified table name in the SQL wins over the default, for example SELECT * FROM default.orders (see qualified table names).
  • USE <db> and USE BRANCH <b> on a socket with a default get an error that names setContext: the default wins over USE, so USE changes nothing.
  • db.sql() sends the database as POST /sql?db=<name>. POST /sql has no branch selector, so db.sql() with a default branch other than main throws a KineDBError. Use a socket, or name the branch in the table name (app@dev.orders).
  • A database that does not exist gets database not found: <name>. A branch that does not exist gets branch not found: <name>.
  • putFile, getFile and fileInfo with no db and no branch use the default too.
  • transaction() on a default branch other than main is refused. Transactions on a branch are not supported yet.

Run a query as a token user ​

A web server can keep one socket for the requests of all its users. Each request carries the bearer token (kdt1_…) that its user got from POST /login. ws.queryAs(token, sql) runs ONE statement with the rights of the user of that token (the frame QueryAs, B2843). The statement never has the rights of the socket.

js
import { KineDB, SESSION_REJECTED } from '@kinedb/client';

// The socket logs in as 'webapp'. Root ran: GRANT QUERY AS TO 'webapp'.
const ws = await new KineDB(url).connect();
await ws.authenticate('webapp', password);

const rows = await ws.queryAs(token, 'SELECT id, total FROM orders');
const one = await ws.queryAs(token, 'SELECT * FROM orders WHERE id = $1', {
  db: 'shop',
  params: [42],
});
  • The user of the socket, or one of its roles, must hold GRANT QUERY AS (GRANT QUERY AS). No user has it by default. root needs it too.
  • The reply is the reply of ws.sql(). db and branch win over the default of the socket. params are the values of $1, $2, …, as for sql().
  • The server checks the token at each call. Each failure gives a KineDBError with the text session expired or revoked: log in again (SESSION_REJECTED). A socket with no grant gets the same text. Do not retry it.
  • WATCH, ALTER WATCH, UNWATCH, USE, BEGIN, COMMIT and ROLLBACK are refused. Each refusal has its own text: QUERY_AS_NO_SUBSCRIPTION, QUERY_AS_NO_USE and QUERY_AS_NO_TXN_CONTROL. A peer connection gets QUERY_AS_CLIENT_CONNECTION_ONLY. The package exports each text, so compare err.message with it.
  • A server that does not know the frame gives SERVER_TOO_OLD_FOR_QUERY_AS (the server is too old for QueryAs: it needs protocol version 6).
  • The client does not retry queryAs. A backoff reply and a retryable error throw a KineDBError with err.retryable set. The retry option does not apply.
  • The read budget is 30 s (QUERY_AS_TIMEOUT_MS). Set timeoutMs to change it. With no reply in that time, the client closes the socket and rejects every request on it. Thus a late reply never answers a later call. Open a new socket and log in again.
  • No error text of the client contains the token.

See Run a query as a user of a token.

Retrying ​

With no retry option, the client retries a typed hold refusal only (B2298). The refusal kind is write_hold or not_ready, and the server wrote nothing. The client sends the request again after the retry_ms of the server, for up to 20 s (holdBudgetMs). This applies to sql(), transaction(), a socket and putFile(). A file upload sends again only the refused chunk frame or manifest frame. Every other retryable error throws at once with err.retryable set.

  • retry: false turns off every retry, the hold retry too.
  • retry: true, or an object of options, also retries the other retryable errors.

See A write fails while a shard splits, merges or moves its leader and the README of @kinedb/client.

The outcome is unknown ​

A KineDBOutcomeUnknownError (a subclass of KineDBError) tells you that the server sent the 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. No retry option sends the request again, also not retry: true. Read the data back before you send the request again. @kinedb/embedded raises the same error on a connection.

js
import { KineDBOutcomeUnknownError } from '@kinedb/client';

try {
  await db.sql("INSERT INTO orders VALUES (42, 'paid')");
} catch (err) {
  if (err instanceof KineDBOutcomeUnknownError) {
    // Read the row back. No row: the insert did not apply, so send it again.
  }
}

See A forwarded write fails: the outcome is unknown.

A reply does not parse ​

The socket matches each reply to the oldest request that waits. A reply frame that does not parse breaks that match, so the client fails the whole connection (B2890). Every request that waits on the socket throws a KineDBError WS parse failed: <reason>, and the socket closes. A later request throws connection closed. Open a new connection. A write that waited on the closed socket MAY have applied: read the data back before you send it again. A watch push that does not parse does not fail the connection: the client logs a warning and drops the push. The Rust client poisons its connection the same way.

The schema version of a table ​

A rows result over a socket has tableVersions: an array of [table, version], one pair for each table that the statement read. A rows result of sql() over HTTP does not have it (the result of sql()). A watch_fill, a watch_tail and a watch_data frame have schemaVersion: the version of the watched table.

js
const r = await ws.sql('SELECT * FROM orders');
// r.tableVersions  →  [['orders', 57]]
  • The version is the stamp of the last DDL on the table. It is a Number. A value above 2^53 - 1 is a BigInt. A standalone server can give one, for example when a release before B3163 ran a CREATE INDEX on its data directory.
  • The version changes at each DDL of the table: ALTER TABLE, CREATE INDEX, DROP INDEX, SET CONFLICT POLICY, also a GRANT or a change of a policy. DROP TABLE and then CREATE TABLE gives a later version.
  • The version never changes at a row write. An INSERT, an UPDATE and a DELETE leave it as it was.
  • The version never goes down. Compare two versions only for equality: a different value means that a DDL ran. The change can be one that does not touch the columns, so read DESCRIBE again to see the new shape.
  • 0 or an absent value means that the version is not known. tableVersions is [] when the server sent no version, and schemaVersion is 0.
  • DESCRIBE <table> has the same value in its last column, schema_version.
  • Do not compare the version of a server with the version of an embedded database (the embedded package). The two counters are not the same.

Describe a table ​

describe(table) returns the schema of one table. Both packages have it, on every connection object.

js
const d = await ws.describe('users');
// d.schema_version  →  57
// d.primary_key     →  ['id']
// d.columns[1]      →  { name: 'email', type: 'Text', not_null: true, default: null,
//                        primary_key: false, unique: true, indexes: ['users_email'] }
// d.indexes[0]      →  { name: 'users_email', columns: ['email'], unique: true }
  • The keys are in snake_case in the JavaScript and in the Python packages.
  • A column is unique only when an index of that one column is UNIQUE. A composite unique index does not make its columns unique.
  • default is the SQL literal of the default, or null.
  • The call reads DESCRIBE, SHOW INDEXES FROM and DESCRIBE again. When the version moves between the two DESCRIBE reads, it starts again. After 3 reads it throws.

Transactions ​

transaction(statements, options) sends ONE complete transaction in ONE request. The server writes all the statements, or none of them:

js
await ws.transaction([
  ['INSERT INTO orders (id, item) VALUES ($1, $2)', [7, 'pen']],
  ['UPDATE stock SET n = n - 1 WHERE item = $1', ['pen']],
], { token: 'order-7', retry: true });
await db.transaction(['DELETE FROM carts WHERE id = 3']);   // POST /sql
  • A statement is a string, [sql] or [sql, params]. The params are the parameters of sql(text, params).
  • The socket sends the Transaction frame (0x1E). db.transaction() sends the same payload as POST /sql with content-type: application/vnd.kinedb.sql-txn.
  • The answer is { type: 'created' }. An error throws a KineDBError, and nothing of the transaction is written. The answer gives no row counts.
  • No transaction stays open on the server. sql('BEGIN'), sql('COMMIT') and sql('ROLLBACK') are refused. Two transaction() calls on one socket are two separate transactions.
  • A SELECT in the list gives no rows. For a read, a decision and a write in one step, CALL a function.
  • token is the commit token. A second transaction with a token that committed applies nothing and answers created.
  • retry has the shape of the retry option of the client and wins over it. A retry sends the WHOLE transaction again, never a part.
  • db and branch win over the default of the client.

See Transactions.

Watch ​

ws.watch(table, callback) sends WATCH <table> and calls callback with each frame that the server pushes for that table. A pushed frame never answers a statement: a statement in flight keeps its own reply.

watch(table, callback, { where, params }) adds WHERE <where> to the statement. params holds the values of the $1, $2, … of where, with the types of sql(text, params). The server binds them, so a value is never parsed as SQL. Put each filter value in params, not in the text of where (B3111). params without where throws a TypeError.

js
const sub = await ws.watch('orders', (n) => console.log(n), { where: 'owner = $1', params: [userId] });

table goes into the text of the statement, so it must be a table name. A name has one to three parts with . between them, for example orders or shop.orders. A part is a word ([A-Za-z_][A-Za-z0-9_]*) or a bracket-quoted name such as [order lines]. A part can have an @branch, as in shop@dev.orders. Any other text, for example orders WHERE 1=1, throws a TypeError. Both checks run before the client sends a frame.

n.type tells the frames apart:

n.typeThe frameWhat it carries
watch_notifyWatchNotify (0x22)table, changed: the count of a plain watch, and operation when the server names it (insert, update, delete, or rights with changed: 0 after a rights change, B3043)
watch_dataWatchTypedData (0x27)columns, types, data: the typed rows of a VERBOSE watch, with typed: true. It also has schemaVersion (B2480). The server no longer sends the old WatchData (0x23); the client fails the connection if it gets one
watch_fillWatchFill (0x24)the fill of a WATCH … SINCE subscription: upserts, removes, token, full, more, upsert_versions, upsert_manifests, upsert_predicates, error, gap (B2201: the gap answer of a watch with ALLOW GAP, or null), schemaVersion (B2480)
watch_tailWatchTail (0x28)one diff of a WATCH … SINCE … LIVE subscription: the watch_fill fields plus base, the token the diff starts from

The rows of upserts and removes are typed cells, as in a typed rows result. A non-empty error marks a fill that failed: it has no rows and no token. upsert_predicates has one list for each upsert row: the ids of the predicates of the watch that match the row. It is empty for a watch with a bare WHERE. See A set of predicates.

A fill comes in pages (B2200). A watch_fill or watch_tail frame with more: true is one page. Its token is a resume token: a cursor (c1:…) or a commit hash. Do not parse it. The server sends the next page only when the client asks for it with WatchFillNext (0x29). The client asks after every callback returned. When a callback returns a promise, the client asks when the promise resolves. Store the page rows and the token together before that promise resolves.

B3026: a fill can have a window of pages in flight, so one round trip does not cost one page. watch(table, callback, opts) takes these options for a fill:

OptionMeaning
since'START', or the token that you stored. It starts a fill.
rightsThe rights stamp of that token (B2681). Without since, the client ignores it.
livetrue keeps the tail live after the fill. Without since, the client ignores it.
windowThe pages in flight. The default is 16. The server caps it at 32. 1 keeps the paced fill above.

With since and no window, the client writes WINDOW 16. A server from before B3026 refuses that clause, so pass window: 1 for such a server.

With a window, the client gives the pages to the callbacks in page order, one page at a time. It sends one ack with the token of a page after each page except the last, and not after a failure. It holds the acks back while the pages that it received and did not deliver are more than 16 MiB. The memory bound is about 16 MiB plus the window times 4 MiB. A failure frame drops the pages that it did not deliver. A tail diff (watch_tail) always has window 1.

A second watch() of a table with since starts a second fill, after the first fill ended: the server refuses it while a fill of the table is in progress. Every callback of the table gets the pages of the second fill, from its first page.

An old ack can count for a new fill of the same table with the same SINCE and the same head, because the page tokens are equal. The server bounds this: the client gets at most the old window of extra pages, so at most the old window plus the new window (64 or fewer) pages are in flight to it.

When a callback throws or its promise rejects, the client does not ask. After 120 s with no ask, the server ends the fill with a failure frame (retry: true). Then subscribe again with WATCH t SINCE '<the stored token>'. A page can have zero rows: store its token too. The last frame (more: false) carries the plain commit token.

Files ​

A socket stores and reads a file of any size. A file has no size limit by design; the client sends chunks of at most 128 KiB, content-defined, so two versions of a file share their unchanged chunks.

The four calls are putFile, getFile, fileInfo and heldChunks. Each one takes an optional db, branch and timeoutMs. The timeout is the budget of ONE frame, not of the whole file, and it defaults to 60 seconds. A SQL statement names its database and branch in the table name instead, for example SELECT * FROM app@dev.orders (see qualified table names).

putFile ​

putFile cuts the bytes at content boundaries, hashes each chunk with BLAKE3 and sends one chunk per frame, at most four unanswered. It then sends the manifest: the size, the BLAKE3 of the whole file and the chunk hash list. It returns that manifest, plus chunkBytesSent: the chunk bytes the call really sent.

The client does not send a chunk again when the shard already holds it. The chunk can come from any file of the same table. Before the upload, the client asks the server which chunk hashes it holds, and it sends only the other chunks. The server decides: when it refuses the manifest, the client sends the skipped chunks and the manifest once more. A file that the shard already holds reports chunkBytesSent 0. A server without this feature gets every chunk, as before.

js
const data = new Uint8Array(await file.arrayBuffer());
const stored = await sock.putFile('files', 'report.pdf', data);

console.log(stored.size);           // 4182003
console.log(stored.hash);           // the blake3 of the whole file, 64 hex characters
console.log(stored.chunks.length);  // 64, each one 64 hex characters

putFile takes an optional fields object in its options. A table takes it as the column values of the file row. A collection adds it to the manifest document. A user with only a MATCH permission writes a file this way, because the policy judges the row that the write leaves. The server refuses the keys name, path, size, hash, chunks, chunk_count and _id. An empty object counts as no map. A value that is not a plain object throws a TypeError before the client sends any byte. The server must be of a build that knows the map: an older server ignores it.

js
await sock.putFile('files', 'report.pdf', data, { fields: { tenant: 'acme' } });

getFile ​

getFile reads the manifest, then fetches every chunk and verifies it. A chunk whose bytes do not hash to the name the manifest gave throws, and so does an assembled file that does not match the whole-file hash. No bytes come back from a failed read: a caller never sees a file that was only partly verified.

js
const bytes = await sock.getFile('files', 'report.pdf');
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));

fileInfo ​

fileInfo reads the manifest alone, with no chunk transfer. Use it to learn the size of a file, or to compare two versions by their chunk lists.

js
const info = await sock.fileInfo('files', 'report.pdf');
console.log(info.size, info.hash, info.chunks.length);

heldChunks ​

heldChunks asks the server which chunks of a file it holds. It takes the hex chunk hashes that putFile gives, and gives one flag for each hash, in the same order. Use it to show how much of a file is on the server after an upload that stopped half way.

js
const held = await sock.heldChunks('files', 'report.pdf', stored.chunks);
console.log(held.filter(Boolean).length, 'of', held.length, 'chunks are on the server');

It is the question that putFile asks before it sends a chunk (FindHeldChunks). A long list goes in parts of FIND_HELD_CHUNKS_MAX (16,384) hashes. The answer is a hint: a user who cannot read the whole table gets false for every hash. heldChunks throws when the server did not grant the file skip at login (sock.fileSkipGranted is false), because an old server does not answer the question. A hash that is not 64 hex digits also throws. The embedded engine has the same call on its connection.

The server must support files ​

The file calls need a server build with file support. Every server mode stores files: one server without --calvin, a one-node cluster, and a cluster of several nodes. The client asks for file support on the login frame, and the server echoes the grant; sock.filesGranted reports the answer. On a logged-in socket with no echo, every file call throws this server has no file support (FLAG_FILES not granted). A socket that never logged in is not checked, because an open server answers without a login and then the grant was never on the wire.

js
await sock.authenticate('root', 'hunter2');
console.log(sock.filesGranted);  // true

The embedded package: the engine in your process ​

@kinedb/embedded is the embedded shape of kinedb. It bundles the engine as WebAssembly and runs the database inside your application — in a Web Worker in a browser, in bun 1.4.2 or newer, and in Node 22.3 or newer. It needs no server and no port to read and write its own data, and it can also open a connection to a running kinedb-server when you want one. In a browser it needs no bundler: a Web Worker can import it as a plain ES module, from a CDN or through an import map.

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>.

A build is named by the commit that produced it, like a docker image:

npm install @kinedb/embedded@ga1b2c3d4e5f6     # the commit
npm install @kinedb/embedded@pr-740            # the head of a pull request

The package ships the .wasm inside it, so there is nothing else to fetch.

Runtimes ​

The package needs bun 1.4.2 or newer, or Node 22.3 or newer. A browser needs WebAssembly, and OPFS for a durable database.

Use ​

js
import { KineDB } from '@kinedb/embedded';

const db = new KineDB('shop');

await db.sql('CREATE TABLE acct (id INT PRIMARY KEY, bal INT)');
await db.sql('INSERT INTO acct (id, bal) VALUES (1, 100)');

const r = await db.sql('SELECT id, bal FROM acct');
// r.type     'rows'
// r.columns  ['id', 'bal']
// r.types    [{code: 2, name: 'Int', param: 0}, {code: 2, name: 'Int', param: 0}]
// r.data     [[1, 100]]
// r.count    1

await db.close();   // each commit is already durable — see below

The constructor is SYNCHRONOUS and LAZY. The first await db.sql(...) loads the .wasm and opens the database, because sql() is already async in @kinedb/client and the import line must stay one line.

Options ​

js
new KineDB(name, { dir, wasm, host, memory, memoryBudget });
optionwhat it does
dirthe directory the default node:fs host roots at. Default: the working directory, so new KineDB('shop') is ./shop.
wasmwhere the module is: a path, a URL, an ArrayBuffer/Uint8Array, or a compiled WebAssembly.Module. A path is a POSIX path, a Windows path (a drive path, a UNC path, a root-relative or a drive-relative path), or a path relative to the working directory. A runtime with no working directory (a browser) refuses a relative path: pass a URL, for example new URL('k.wasm', import.meta.url). Default: the .wasm shipped in this package.
hostan explicit host, which overrides the default choice.
memorytrue opens an in-memory database. It writes NO file.
memoryBudgetthe bytes the engine may use (B1429, B1610). The block cache takes 1/3, the two footer-window caches 1/16 and 1/64, and the tree-node cache 1/144 (at least 1 MiB), the embedded plan of MemoryPlan in kinedb-cache. Minimum: 4 MiB. A wasm memory never shrinks, so a large budget keeps the page large. Default for a named database: 32 MiB. Default for memory: true: no budget; see An in-memory database.
durability'commit' (the default) or 'manual'. See Durability: two modes. Another value is an error at open.

Durability: two modes ​

'commit' (the default). A statement that commits answers only after its data and the HEAD file are on disk. A power cut, a crash of the process or a closed tab after the answer loses no committed write. This includes the offline writes that sync() keeps for the next push(): the op-log row and the table row are one local commit, so they reach the disk together. A transaction([...]) call syncs once, at its commit. A statement that reads does not sync.

'manual' (opt-in, fast). A write stays in memory until you call flush(). A crash loses EVERY write since the last flush, not only the last few rows, and the offline op-log with them. Use it when the application accepts that loss, for example a bulk import that it can run again:

js
const db = await KineDB.open('import', { durability: 'manual' });
for (const row of rows) await db.sql(insertOf(row));
await db.flush();

The cost. Each durable commit is one sync of the disk. On a native disk (Linux, ext4) 500 single-row INSERTs took about 13 ms each in 'commit' mode and about 3.5 ms each in 'manual' mode. Put many rows in one transaction([...]) call to pay the sync once.

If the sync fails in 'commit' mode, the statement answers an error that says the write is applied but not durable. flush() tries the sync again. A failed fsync of a data segment file is different (B2960): the disk can lose the data of a failed fsync, so kinedb does not try again. The database stops. On wasm32 the panic traps the module. Restart the process.

In a browser, the 'commit' mode writes HEAD.tmp on each commit, so the OPFS host needs its pool of spare files (the default poolSize is 8). An OPFS host with poolSize: 0 opens only in 'manual' mode.

flush() ​

flush() makes every write durable. In 'commit' mode each commit already is, and flush() has no work. In 'manual' mode, call it when a batch of writes is done, and before a tab closes:

js
await db.sql("INSERT INTO acct (id, bal) VALUES (1, 100)");
await db.flush();

close() does not flush, on purpose: in 'manual' mode durability is a call you make, so discarding a scratch database stays possible.

One opener per database ​

A database has one opener (B2150, B2160). A second open of the same database fails with B2150: the database directory … is already open. This is true for a second Node process, a second KineDB in one process, a second Web Worker and a second browser tab. Call close() before another opener takes the database.

  • Node: the host writes <dir>/<name>/LOCK with its pid, its host name, the start time of the process and its pid namespace. close() and a normal exit remove the file. After a crash, the next open takes over the LOCK of a process that is gone.
  • Browser: the host takes a Web Lock (navigator.locks). The browser frees it when the tab or the worker closes.

A host of your own (host:) must supply the imports fs_lock_dir and fs_unlock_dir. See src/node-host.js for the contract. The repairs are in web/docs/troubleshooting.md, "the data directory is already open".

In a browser ​

The engine needs a synchronous filesystem, and the only one a browser has is the Origin Private File System through createSyncAccessHandle, which exists in a Web Worker and not on the main thread. So run this package in a worker.

OPFS reaches a file with an ASYNCHRONOUS call, and a wasm import must be synchronous, so the OPFS host answers "not yet" and records what it needs. KineDB.open() drives that retry loop for the open:

js
// inside a Web Worker
import { KineDB } from '@kinedb/embedded';

const db = await KineDB.open('shop');        // drives host.prepare() for you
await db.sql("INSERT INTO acct (id, bal) VALUES (1, 100)");
await db.flush();

After the open, no call needs a prepare. The host keeps a pool of spare files per database in <name>/.opfs-pool/ (8 by default), and a file the database creates later — the HEAD.tmp of a durable commit, the next 64 MiB segment — takes a spare at once. The package refills the pool before every call, and putFile refills it between its 1 MiB slices. A name map in the same directory, with a checksum per record, keeps each file under its name across a reload. createOpfsHost(root, { poolSize }) sets the size; 0 turns the pool off.

A rename is atomic, also after a crash (B2839). OPFS has no synchronous rename, so when the name map cannot move a name, the host copies the bytes. The copy runs under a redo journal, <name>/.opfs-rename: the host writes an intent record before the copy and clears it after. The next open finishes or redoes a copy that a crash stopped, so a file such as HISTORY_FLOOR or DICTS holds its old record or its new record, never a torn mix. The host hides the journal from the database, as it hides the pool.

A reopen of a durable database creates no file: the open continues the last segment (B1725). So a page that opens its database at every load keeps the same files. A pool that older versions grew stops growing. Its extra spares are 0-byte files, and the next creates use them before the pool makes a new spare.

One host serves one database. Several databases can share one OPFS root, each with its own host, in one worker or in several. A host opens sync access handles only of the files of its own database, so it never blocks the open of another database under the same root (B2184).

One statement that creates more new files than the pool holds (more than 512 MiB of segments, with the default of 8) fails, and so does every create with the pool off. The message names the paths. Open them and repeat the call:

js
await db.host.prepare([...db.host.pending]);

A local statement is NEVER retried for you: a failed INSERT that partly applied and then ran again would write the row twice. (A statement on a connect() connection is different: see the hold retry in "Connect to a server".)

make test-browser (from the repository root) runs this package in a real headless Chrome, on a real OPFS (B1155). It writes, flushes and reads back in a new tab. It kills the browser with SIGKILL and reads the flushed rows after the relaunch. It stores a 72 MiB file across a segment rotation and reads it back byte-exact after a restart. It starts a kinedb server and syncs an empty database from it: the first sync is a pack, and the rows stay after a restart of the browser (B3501). It needs Chrome or Chromium, the staged wasm (make wasm, then bun run build here) and a server built from the tree (KINEDB_SERVER_BIN, else target/release/kinedb). The gate does not run it, because the gate has no browser (owner 2026-09-30, B1468: not for now). The check serves the files as they are, with no bundler, so it also proves the next paragraph.

The package loads in a browser WITHOUT a bundler (B1469): a Web Worker can import src/index.js as a plain ES module, from a CDN or through an import map. No module of the package imports a node: module statically. src/node-host.js reaches node:fs through process.getBuiltinModule, so Node needs 22.3 or newer. src/no-bundler.test.js walks the static import graph as such a browser walks it.

An in-memory database ​

js
const db = new KineDB('scratch', { memory: true });

It writes no file. The database lives in the wasm memory and goes away with it.

With no memoryBudget, the memory keeps every row. Give a budget to set a limit:

js
const db = new KineDB('scratch', { memory: true, memoryBudget: 16 * 1024 * 1024 });

The rows may then use the budget less the tree-node cache and less 1/3 for the work of the statements. A write past that share fails with an error that names the budget, and the rows committed before it stay readable.

The result of sql() ​

A statement that writes:

js
{ type: 'created' }                          // CREATE, and a transaction([...]) call
{ type: 'inserted', count: 1 }               // also 'updated', 'deleted'

A statement that reads:

js
{ type: 'rows',
  columns:  ['id', 'name'],
  types:    [{ code: 2, name: 'Int', param: 0 }, { code: 4, name: 'Text', param: 0 }],
  data:     [[1, 'ada']],
  count:    1,
  schemaId: null }

types[i].code is the frozen table COLUMN_CODES, which this package exports. The JS value of a cell, by code:

codenamethe value you get
0Nullnull — every cell of the column is NULL
1Boolboolean
2IntNumber, or an exact BigInt outside ±(2^53−1)
3FloatNumber, including NaN and both infinities
4Textstring
5BytesUint8Array
6TimestampNumber, or an exact BigInt outside ±(2^53−1)
7Jsonthe PARSED value; the raw text when it will not parse
8BlobArray<Uint8Array(32)> — one BLAKE3 chunk hash each
9VectorFloat32Array of types[i].param elements

An INT past 2^53 comes back as an exact BigInt, not as a rounded Number, so every integer this engine stores reads back with all of its bits.

schemaId is always null. It is the cache key of the WebSocket schema cache in @kinedb/client; an embedded reply carries its header every time, so there is no cache to key.

Parameters ​

sql(text, params) takes the values of $1, $2, … apart from the text. The engine binds them, so a value is never parsed as SQL. The same call works on a synced table and on a Connection.

js
const name = "it's'); DELETE FROM t; --";
await db.sql('INSERT INTO t (id, name) VALUES ($1, $2)', [2, name]); // one row, a string
await db.sql('SELECT id FROM t WHERE name = $1', [name]);

The type table is the one of @kinedb/client: an integer number or a bigint → INT, a fractional number → FLOAT, string → TEXT, boolean → BOOL, Date → TIMESTAMP (microseconds), Uint8Array → BYTES, Float32Array → VECTOR, null or undefined → NULL. Any other value throws a TypeError or a RangeError before the statement runs. src/params.js is a verbatim copy of the client file, and src/params.test.js fails when the two copies differ.

Transactions ​

A transaction is ONE call (B2174). There is no open transaction: sql('BEGIN'), sql('COMMIT') and sql('ROLLBACK') throw, and the error names transaction(). The same call works on @kinedb/client.

js
await db.transaction([
  ['INSERT INTO acct (id, bal) VALUES ($1, $2)', [1, 100]],
  ['UPDATE acct SET bal = bal - $1 WHERE id = $2', [10, 2]],
  'INSERT INTO audit (id) VALUES (7)',
], { token: 'transfer-42' });       // { type: 'created' }
  • An item is a statement (a string, [sql] or [sql, params]) or a file ({ putFile: [collection, name, data] }, see Files).
  • Every item commits in one commit, or none of them. The error of the item that failed is the error of the call.
  • token makes a retry safe: a call whose token committed before applies nothing and answers created.
  • A read in the list runs against the committed state and gives no rows back. DDL cannot be an item.

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.

js
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 the database and on a Connection. src/describe.js is a verbatim copy of the file of @kinedb/client, checked by src/params.test.js.

Call a function ​

call(name, args) calls a function on the server with typed arguments. It is on a Connection and on the database. The rules are the ones of @kinedb/client (src/call.js is a verbatim copy, checked by src/params.test.js):

  • the connection reads SHOW FUNCTIONS one time and keeps every signature;
  • each argument is converted to the type of its parameter (2 into a FLOAT is a FLOAT, an object into a ROW is JSON); a value that does not fit throws before anything is sent;
  • a stale signature (the server refusal signature) gives one new read and one new send;
  • RETURNS <type> gives the value, RETURNS TABLE (…) gives row objects.
js
const conn = await db.connect('ws://127.0.0.1:8080/ws', { user: 'root', password: '…' });
await conn.call('add_points', [1, 50]);           // 51
await db.call('add_points', { player: 1, points: 50 });
await db.call('add_points', [1, 50], { offline: 'refuse' });

db.call() is ONLINE when the database has a live connect() connection: the call goes through it. The sync session does not count, because it has no statement link to the server. OFFLINE, the embedded database cannot run a function yet (B2175), so the offline option only picks the words of the refusal:

offlineoffline result
'preview' (the default)a KineDBError: the database cannot run or queue a function yet (B2175)
'queue'the same KineDBError (the queue is B2175)
'refuse'a KineDBError: call(<name>) needs the server: the database is offline

Any other offline value throws a TypeError, online and offline.

Errors ​

A failed statement rejects with a KineDBError carrying the engine's own message:

js
import { KineDBError } from '@kinedb/embedded';

try {
  await db.sql('DESCRIBE absent');
} catch (err) {
  err instanceof KineDBError;   // true
  err.message;                  // 'NotFound: table not found: absent'
}

A SELECT from a table that does not exist throws table not found: <name>, the same error as UPDATE, DELETE and DESCRIBE. An empty table answers zero rows.

On a connection, a KineDBOutcomeUnknownError (a subclass of KineDBError) tells you that the server sent the statement 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. The connection never sends the statement again. Read the data back before you send it again.

Rows as objects ​

js
import { rowsAsObjects } from '@kinedb/embedded';

const r = await db.sql('SELECT id, name FROM people');
rowsAsObjects(r);      // [{ id: 1, name: 'ada' }]

The same function @kinedb/client exports, with the same behaviour: a result that is not rows answers [], so it is safe on whatever sql() returned.

Files ​

putFile and getFile have the shape of @kinedb/client:

js
await db.sql('CREATE TABLE photos (name TEXT PRIMARY KEY)');
await db.sql('CREATE TABLE albums (id INT PRIMARY KEY, photo TEXT)');

const put = await db.putFile('photos', 'beach.jpg', bytes);   // a Uint8Array
// put.size    the byte length
// put.hash    the blake3 of the whole file, hex
// put.chunks  the chunk hashes, hex, in file order

// A file and a row in ONE transaction: they land together, or not at all.
const done = await db.transaction([
  { putFile: ['photos', 'sea.jpg', bytes] },
  ['INSERT INTO albums (id, photo) VALUES ($1, $2)', [1, 'sea.jpg']],
]);
// done.type   'created'
// done.files  the record of each file item, in list order: [{size, hash, chunks}]

const back = await db.getFile('photos', 'beach.jpg');   // a Uint8Array

The engine cuts the file into the same content-defined chunks as the server and @kinedb/client (BLAKE3, at most 128 KiB each). The file goes into a TABLE with a TEXT primary key (the file name is the key), or into a schemaless collection.

A FILE AND A ROW COMMIT IN ONE TRANSACTION (B2174). A { putFile: [collection, name, data] } item of transaction([...]) commits with the other items of the call. An error of any item commits nothing: no file and no row. The call reads the source and stores the chunks first, and the commit names them; a failed call leaves only orphan chunks, which the next sweep removes.

getFile checks every chunk against its hash and the whole file against its hash. A missing file throws a KineDBError with the message file not found: "<name>" in <table>, as @kinedb/client does.

A file is durable when its write commits, the same as a row (in 'manual' mode: after flush()).

A file larger than the memory (B1429) ​

A file moves through the engine ONE PIECE AT A TIME, so the wasm memory holds a chunk and never the whole file. putFile takes a stream as well as bytes:

js
// a Uint8Array, an ArrayBuffer, a Blob, a ReadableStream<Uint8Array>,
// or an (async) iterable of Uint8Array
const put = await db.putFile('videos', 'talk.mp4', file.stream());

const stream = await db.getFileStream('videos', 'talk.mp4');   // or null
for await (const chunk of stream) {
  // one verified chunk (at most 128 KiB) per pull
}

putFile never concatenates the pieces, and a piece larger than 1 MiB goes into the engine in 1 MiB slices. The answer is the same {size, hash, chunks} as for the same bytes in one Uint8Array. When the source fails, or a piece is not bytes, putFile rejects, nothing lands, and a ReadableStream or Blob source is cancelled. A { putFile: [...] } item of transaction([...]) takes the same sources.

getFileStream answers null for a missing file. It reads a chunk only when you pull one, and checks it against its hash. The pull of the LAST chunk also checks the size and the whole-file hash, and ERRORS the stream instead of giving that chunk when either fails, so a damaged file never ends as if it were whole. cancel() releases the reader, and so does the garbage collector for a stream you drop unread; close() of the database releases every reader and every unfinished putFile. getFile reads through the same path, so the wasm memory does not hold the file there either; its answer does.

Measured (R4 of B1429, bun 1.4.2, the node:fs host): a 256 MiB file streams in and back out with the wasm memory at 16.4 MiB at its peak.

Connect to a server ​

connect() opens a WebSocket to a running kinedb-server, presents the credential, and answers a connection. With a user and a password, a user and a key, or a token, it answers only after the server GRANTS the login, so the connection you hold is one you can use. With {anonymous: true} it presents nothing and there is no grant to wait for — see the four shapes below.

js
const conn = await db.connect('ws://127.0.0.1:8080/ws', {
  user: 'root',
  password: '…',
});

const r = await conn.sql('SELECT id, bal FROM acct');
// The SAME result shape `db.sql()` answers, key for key.

await conn.close();

It takes ONE of four credential shapes. Name the one you want:

js
await db.connect(url, { user: 'root', password: '…' });  // a user and a password
await db.connect(url, { user: 'alice', key: signer });   // a key pair
await db.connect(url, { token });                        // a session bearer
await db.connect(url, { anonymous: true });              // a permissive server
shapewhat the connection presents
{user, password}the login the server checks against that user.
{user, key}a signature of a new server challenge, made by the private key of that user.
{token}a session bearer, for example the token POST /login answers.
{anonymous: true}nothing. The connection never logs in.

connect(url, {}) THROWS, and that is deliberate. A caller who leaves the credentials out gets the error AT THE CALL, instead of an unauthenticated connection that fails at its first statement. So anonymous access is a shape you ask for by name, and it is never the default.

Two things a reader does not guess, and both matter:

  • The password shape and the token shape ride the SAME login frame. A token login sends an empty user name and an empty password, because the server reads the token alone and never looks at the other two fields.
  • {anonymous: true} sends NO frame at all. A permissive server serves a connection that never logs in, and an enforcing server refuses it. "No credentials" and "empty credentials" are different things on the wire: a login frame that carries an empty user name and an empty password is refused with invalid username or password, whatever the server's mode.

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, and this package never holds it: you give a signer, and the signer holds the key.

js
import { keySignerFromPkcs8 } from '@kinedb/client';

// The .pk8.pem file of the web console, or the PKCS#8 DER bytes.
const signer = await keySignerFromPkcs8(pem);
const conn = await db.connect(url, { user: 'alice', key: signer });
await db.sync('orders', { url, user: 'alice', key: signer });
  • The signer is {keyId, sign(bytes) → Promise<Uint8Array>}. keySignerFromPkcs8 of @kinedb/client is one source of a signer: it imports the ECDSA P-256 key with WebCrypto as a NOT extractable CryptoKey. This package does not import @kinedb/client, and any object of that shape works.
  • keyId is the lower-case hex SHA-256 of the 65-byte public point (0x04 || x || y), 64 characters. sign returns the raw r || s signature (64 bytes) of SHA-256 over bytes.
  • The credential is checked at the call. A key without a 64-hex keyId, without a sign function or without user throws. A key next to a password or a token is two shapes, and it throws too.
  • Each login asks the server for a new challenge, with a new 32-byte nonce from crypto.getRandomValues, and signs it once. A signature is never sent twice. The package does not retry a refused login.
  • reconnect() and every new sync session sign a NEW challenge with the same signer. The package keeps the signer, never a signature.
  • Errors: a wrong key and an unknown user both get the server text invalid username or password. A server with no key login gives the server is too old for a key login; update the server. A signer that throws gives the key did not sign; the signer's own error is in err.cause only, never in the message. A signature that is not 64 bytes is refused. In both cases nothing more goes to the server.
  • The signer has 30 s to answer (the challenge validity of the server). After that, the login fails with the key did not sign in time, and a later signature is dropped, never sent.
  • 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.

A connection carries four methods.

methodwhat it does
sql(statement)runs one statement on the server and answers the shape db.sql() answers.
watch(table, callback)subscribes to a table. It answers when the server acknowledges, and the callback then gets every change notification.
reconnect()opens a new socket, presents the SAME credential again, and re-issues every subscription.
close()shuts the socket. Every later statement rejects.

A statement or a transaction on the connection that gets a typed hold refusal (refusal.kind write_hold or not_ready) is sent again for you (B2298). The server sent the refusal before the write took effect, so nothing was written. The connection waits the retry_ms of the server between two attempts, for up to 20 s from the first send. A transaction is sent again WHOLE. Every other error rejects at once, as before. retry: false turns the hold retry off, for the connection or for one transaction:

js
const conn = await db.connect(url, { user, password }, { retry: false });
await conn.transaction(statements, { retry: false });

watch() answers an object with the server's acknowledgement and a cancel():

js
const sub = await conn.watch('orders', (n) => {
  console.log(n.table, 'changed', n.changed, 'row(s)');
});

sub.ack.table;      // 'orders'
await sub.cancel(); // sends UNWATCH and stops the callback

cancel() stops the callback at once and sends UNWATCH to the server, so the table stays cancelled across a later reconnect().

A reconnect() RESUMES every subscription that is still live. It does not replay what changed while the socket was down: a notification names a table and a row count, so read the table again after a reconnect if you need the rows.

A reconnect() also RE-PRESENTS the credential the first connect() took, and the same one. A connection opened with a token logs in again with that token, a connection opened with a key signs a new challenge with the same signer, and an anonymous connection sends no frame again. So reconnect() never asks the caller for a credential a second time.

The socket comes from the host. Both hosts this package ships carry the bridge, so a default database connects with no setup. An in-memory database gets a host that reaches nothing, by design — pass host: when you want one that connects.

Both hosts take the socket class as an option (B1460). The default is globalThis.WebSocket. Pass a class of your own to wrap the sockets of ONE database, for example to drop the link on purpose in a demo or a test:

js
const host = createNodeHost('./data', { WebSocket: MySocket });
// in a Web Worker: createOpfsHost(await navigator.storage.getDirectory(), { WebSocket: MySocket })
const db = new KineDB('shop', { memory: true, host });

The class needs the surface the bridge uses: new MySocket(url), binaryType, send, close, and the four handlers onopen, onmessage, onclose and onerror.

db.close() closes every connection the database opened.

health ​

db.health() answers the state of the local database. The method name is the direct client's, because both packages answer the same names:

js
await db.health();
// { type: 'health', status: 'ok', embedded: true, name: 'shop',
//   memory: false, open: true, peers: 0 }

sync — an offline-first copy of a table ​

sync() fills a local table from a running kinedb-server and answers when the fill is COMPLETE. After that the embedded database answers reads on its own — with the network gone, with the server stopped — and push() sends your offline writes back.

js
const db = await KineDB.open('cache');
await db.sync('orders', { url: 'ws://127.0.0.1:8080/ws', user: 'alice', password: 's3cret' });

// The rows are local now. This read needs no server.
const open = await db.sql("SELECT id, total FROM orders WHERE state = 'open'");

A session bearer works here too, in place of the user and the password:

js
await db.sync('orders', { url: 'ws://127.0.0.1:8080/ws', token });

A later sync('orders') resumes from where the last one finished and transfers only what changed.

The fill comes in pages of up to 4 MiB. The sync asks the server for a window of 4 pages (B3033): the server sends up to 4 pages before the client acknowledges one. So at most 16 MiB of fill that is not committed is in memory. The client commits each page and its resume token in one local transaction, then it acknowledges that page. The acknowledgement is only flow control: a crash after the commit loses no row, and the next sync() resumes from the committed token. A server from before B3026 refuses the window. Then the session fills at window 1 and writes one warning with console.warn (see Troubleshooting). You set no option for the window.

The first fill of an empty database comes as a pack ​

The first sync() of a NEW or EMPTY persistent database asks the server for a PACK: the server builds a database of the rows of the subscription, with the rights of your login, and sends its files in one stream (B3431, B3490). The device writes the files and opens them as its database. This is much faster than the fill in pages for a large table.

js
const db = await KineDB.open('cache');   // a new name: the open waits for the first sync
const report = await db.sync('orders', { url, user: 'alice', password: 's3cret' });
report.pack;      // { commit: '…' } when the fill came as a pack, else null
report.upserts;   // 0 after a pack: the first resume after the pack brings no row

The rules:

  • Only the first sync() of the database takes a pack, and only when it fits: a user login (not anonymous) and no pack: false option. The pack is of the server database of the call: the database option, else default (B3506). The query of the pack is the subscription of the call: SELECT * FROM <table>, with WHERE <predicate> when you pass one.
  • Any other call before the first sync() (sql(), query(), putFile(), …) opens the database normally, and then the sync does the normal fill.
  • A pack is never a lock-out. When the server refuses the pack, when the server is older than the pack, or when the pack does not open, the same sync() does the normal fill (report.pack is null). A refused query or a server error writes one warning with console.warn. When the socket closes during the pack, the sync() rejects with a retryable error, no file of the pack stays, and the next sync() asks for the pack again.
  • Until the first resume of each table of the pack ends, a query of the app is refused with a "fresh pack" error. sync() runs that first resume itself, so the rows of the table that you synced answer when the call returns.
  • A pack that did not end (a closed tab, a power cut) leaves the marker .pack-incomplete. The next open deletes the name and refuses once (see Troubleshooting); open it again for an empty database.

Sync a table from another server database ​

The sync reads the server database default. Pass database to sync a table from another server database:

js
const db = await KineDB.open('scale-cache');
await db.sync('orders', { url: 'ws://127.0.0.1:8080/ws', token, database: 'scale_demo' });

Each frame of the sync session names that database: the schema read, the subscription, the push() and the file chunks. The value must be a non-empty string. Another value is a TypeError, and no socket opens.

One local database syncs with ONE server database. The first sync binds it to that database, and the local database keeps the binding. A later call that asks for another server database is refused, and the local rows stay:

text
sync target mismatch: this local database syncs with server database 'scale_demo'; the call asks for 'default'. Use a new local database to sync 'default'.
  • A call with a url or a database asks for database, else for default. So on a local database bound to scale_demo, pass database: 'scale_demo' with each url.
  • A call with no url and no database, such as push('orders'), asks for the server database of the sync session. This is also true when the session is offline and waits for the reconnect. With no session (for example after a reload and connect()), it asks for the server database that the local database keeps. With none kept, it asks for default.
  • While a sync session is open, a call that asks for another server database is refused before any frame of the session, with the text above. When the session is offline, a call that needs the server first opens a socket and logs in. Then the refusal comes, before any other frame.
  • Two calls that open a session at the same time do not race: the second call waits for the open of the first, and then uses its session.
  • The automatic reconnect opens the new session with the same server database.

A refused first sync can be done again with the right name. For example, the first sync names a server database that does not exist, and the server refuses it before a row arrives. Close the local database (db.close()), open it again, and sync with the right database. This works only while no synced row or sync state is behind the kept name.

To sync another server database, open a new local database (another name) for it. A local database that has synced rows cannot change its server database (B3032). The sync uses the default branch of the server database: there is no branch option (B3031).

A synced table has a limit on the size of its primary key. The local database keeps the server version of each row under a key that holds the primary key. A key of more than 65,535 bytes (a primary key of about 48 KiB) is refused (B3057).

The first open of a local database that an older build synced rewrites the version shadow automatically (B3028). For 1M rows this took about 14 s on node:fs. The time on OPFS is NOT DETERMINED. The browser console shows no progress during the rewrite (B3067). Do not close the page during it: a crash is safe, and the next open starts the rewrite again.

The local table stays live. After the fill, sync() keeps its subscription, and each later change on the server lands in the local table by itself, with no second sync(). Pass onChange to hear about each one:

js
await db.sync('orders', {
  url: 'ws://127.0.0.1:8080/ws',
  token,
  onChange: (table, report) => render(),
});

report has the fields of the report sync() answers. When the connection drops, freshness() says offline_stale, and the database connects again by itself (see When the connection closes). A table whose fill or diff stopped after it wrote a part of its rows says filling. Pass live: false for a fill that keeps no subscription.

Stop the live tail of a table ​

Call sync() with live: false to stop the live tail of a table that an earlier sync() made live, for example when the user turns "Auto sync" off:

js
await db.sync('orders', { url, token, live: false });

The call fills the table one more time. After it, a change on the server does not reach the local table, and onChange is not called for the table. When the connection closes, the database does not resume the table. The tail stops when the database starts the call, also when the call fails after that. The tail also stops when the socket or the session closes during the call: the call fails, and the database does not resume the table. A resync of the table that waited for the call does not start the tail again.

A call that the database refuses before it starts keeps the tail and onChange. Two examples are a call while another sync of the session runs, and a call with a different predicate than the table holds. After the refusal of a different predicate, the table owes a resume: one UNWATCH and one WATCH with LIVE for the table, as after a lost socket. The reconnect supervisor sends them; with reconnect: false, the next sync() or push() sends them. The next sync() with live: true (the default) starts the tail again.

A table that has a query window (see query()) stays live and keeps its onChange, because a window needs the tail. A table that has a forwarded query and no window is not live after the call: a change on the server does not reach the table until the next live sync().

When the connection closes ​

The database connects again by itself (B2208). When the socket of a sync session with a live table closes, the database waits, then opens a new socket with the same url and credential, and it resumes every live table from its local state. A table that only query() made live is resumed too. The resume sends ONE UNWATCH and ONE WATCH for each table, with the full set of predicates: the sync() predicate, each forwarded query and each window. The delay before an attempt is min(maxDelayMs, minDelayMs * 2^n) * j: n is the count of failed attempts, and j is a random value from 0.5 to 1. The defaults are 500 ms and 30 s. A success sets n to 0 again.

  • For each live table, the attempt first sends the recorded writes of the table (as push() does), then resumes the table. So an offline write reaches the server with no call from your application.
  • A table that does not resume makes the attempt fail. The next attempt uses the open session and resumes only the tables that did not resume yet. The state is 'online' only when every live table resumed.
  • When the server refuses the credential (for example a changed password or an expired token), the attempts stop. The state is 'offline', and detail holds stopped: true and the error. A later sync() or push() with a valid credential connects again.
  • While an attempt runs, the other calls that use the server (sync(), push(), a query() that goes to the server) wait for it.
js
const db = new KineDB('tenant', {
  dir: 'data',
  reconnect: { minDelayMs: 250, maxDelayMs: 10_000 },
});
const unsubscribe = db.onConnectionState((state, detail) => console.log(state, detail));
console.log(db.connectionState); // 'offline' before the first connect
  • connectionState is 'online' (a session is open, logged in, and its resume of every live table finished), 'reconnecting' (an automatic attempt runs now) or 'offline'.
  • onConnectionState(callback) calls callback(state, detail) at each change, never with the same state two times in a row. detail holds attempt, delayMs and error when they apply. It answers a function that removes the callback.
  • reconnect: false turns the automatic reconnect off. reconnect also takes timers ({ setTimeout, clearTimeout }) and random, to control the delay in a test. close() stops the reconnect.
  • reconnect.openTimeoutMs limits the open of a session: the socket and the login. The default is 30 000 ms. At the limit, the half-open session closes, and the attempt fails and waits for the next delay. A sync() or push() that opens a session uses the same limit.
  • A login error that the server marks as retryable (for example the root user is not seeded yet, or a fault of the node that it finds before it checks the password) does not stop the attempts. A fault after the password check gets the normal refusal, so that the answer never tells a correct password from a wrong one; that refusal stops the attempts.
  • Known limits: the time limit applies to the open only. A push or a resume that gets no answer on an open socket blocks the attempt (B2548). A resync can start between two steps of an application sync() or push(), and the step then fails with "already running" (B2547); call it again.

Your application can also call sync() or push() with the url and the credential. That call cancels the pending automatic attempt, opens a new socket, sends the recorded writes, and resumes every live table from its local state. After a failed attempt, the offline session stays, so freshness() and refused() still answer. While the database is offline, query() answers the local rows with their coverage state.

A file comes down with its row. When a synced table holds files, sync() brings each file row with its list of chunks, fetches only the chunks the device does not hold, and checks each one against its hash before anything lands. getFile then reads the file on the device, with no server. A sync that stops half way keeps the chunks that arrived, so the next sync() fetches only the rest. A file the server deleted no longer reads back after the next sync(). The report counts this part: chunksTotal, chunksHeld, chunksFetched and chunkBytesFetched.

A file that the server deletes or changes while a sync() pulls it does not fail the sync(). The device skips that file: it does not write its row, and it removes the older local row of that file. The rest of the pull completes. The report counts each skipped file in filesSkippedChanged. The next sync() brings the delete or the new file.

Writing while the server is away ​

A write to a synced table runs locally and records itself, in the same commit as the row. push() sends those writes back and reports what the server did with each one:

js
await db.sql("UPDATE orders SET total = 120 WHERE id = 7");

const report = await db.push('orders');
// { table: 'orders', sent: 1, applied: 1, merged: 0, rejected: 0, conflicts: 0,
//   chunksTotal: 0, chunksHeld: 0, chunksSent: 0, chunkBytesSent: 0, outcomes: [ … ] }

A file rides the push too. putFile on a synced table stores the file and records it, and push() sends only the chunks the server does not hold, then the file. A push that stops half way sends only the missing chunks the next time, and a push whose answer was lost is answered as replayed the next time, never applied twice. The four chunks… fields report that part of the push. Every putFile source works here, a stream too: the file goes in one piece at a time and the push reads one chunk at a time, so a 128 MiB file and its push keep the memory of the engine near 16 MiB. putFile over a file the server already holds is fine: the push carries the version you read, and when another device changed the file first, the server keeps its file and your write comes back as a conflict.

The server decides each write. One it accepts is applied and leaves your log. One it refuses — because your permissions do not allow it, or because the row changed on the server since you read it — stays visible through refused() with the server's own reason, and discardRefused() is the only call that removes it. A refused write is never dropped in silence.

The log of writes that are not pushed has an index by row key (B3064), so the cost of one write does not grow with the number of writes that wait. The open builds this index on a database that an older build wrote, for a log of any size (B3084). The build writes the index in parts of at most 192 KiB, so its memory does not grow with the log: about 5 MB at 1,000,000 entries (MEASURED in native code). This matters in a browser, where the wasm memory never shrinks. A build that stops in the middle (a closed tab) leaves no half index: the next open removes it and builds the index again. push() does not need the index. With the index present, a write whose primary key is about 49 KB or more is refused (the index key limit is 65,535 bytes). See Troubleshooting.

A table can give a conflict to a function on the server (WITH CONFLICT POLICY FUNCTION, see the SQL reference). When the function merges your write with the server's row, the write is merged: it leaves your log, and the next sync() brings the merged row down. A later write to that row goes to the function again, so it never overwrites the merge.

A table under WITH CONFLICT POLICY LWW or LPW decides a conflict by the time of each write. The push carries the time at which your device made the write. Under LWW the newer write wins the whole row; under LPW each column that you changed wins when your write is newer, and a row that keeps a server column is merged. Under LWW a write over a row that the server deleted wins when it is not older than the delete, and the row comes back; under LPW it is refused. A losing write comes back through refused(). Keep the clock of the device correct: a write more than 5 minutes in the future of the server clock is refused.

LWW and LPW trust the clock of the device. A device clock that is fast by less than 5 minutes is accepted, and its writes win over newer writes from correct clocks, with no error. Field devices often have a clock that drifts. When a lost update is not acceptable, use REJECT or FUNCTION for that table.

A COUNTER or COUNTER(FLOAT) column adds, under every conflict policy. When the row changed on the server since you read it, each counter cell that your write changed becomes the server value plus your amount: the server value + (your value − the value you read). For example, the server adds 3 to 10 and you add 2 to 10: the server row holds 15, and your write is merged. The other columns of the row follow the policy of the table: under REJECT a column that both sides changed makes the write a conflict, and nothing is written; under LWW and LPW the time of each write decides that column. Under LWW and LPW a write that does not carry the row that it started from is refused, because the server cannot tell your amount. The writes of one transaction() call are applied all or none, so a transaction with such a conflict comes back through refused() as a whole.

A DELETE of a row that another device already deleted is applied, not refused: the row is gone, which is what your delete asked for. For example, two devices that clear the same table both push their deletes, and both pushes apply. When the row is live again on the server — another device deleted it and then wrote it again — your delete is a conflict.

You can keep writing while a push runs. A write to a row that the push already sent does not change what the push sent. It is recorded as a new write, and a later push sends it after the server answered the first one. So a row you delete during a push is deleted on the server too, also when the answer of that push was lost.

Knowing what your copy is worth ​

freshness() tells you what the local rows mean, which is not the same question as whether they are there:

js
await db.freshness('orders');
// { state: 'complete_as_of', token: '…' }   a complete copy, as of that commit
// { state: 'offline_stale',  token: '…' }   complete as of that commit, and possibly behind
// { state: 'filling',        token: '…' }   a fill stopped in the middle: as of no commit
// { state: 'never_synced',   token: '' }    these rows say NOTHING about the server
// { state: 'gapped',         token: '…', gap: { reason, since, token, limit, changedRowsAtLeast } }
//                                           live from that commit, but changes are missing

gapped comes from a gap answer of the server (see WATCH limits). The server did not send the changes between gap.since and gap.token: the fill was above a limit (reason server_limit or limit), or the server no longer held your token (retention). A row that you lost in that time (a revoke, a row moved to another owner) must not stay on the device. So the sync never keeps the rows on a gap (B3443): it deletes the synced rows of the table and fills again from SINCE START, once. Your pending writes stay and go out on the next push. When that fill also gets a gap (a table above the server limit), the table stays EMPTY and gapped, and it stays live. A gap on the live tail deletes the rows too; the next sync() fills the table from SINCE START. The sync never answers complete_as_of for a gapped table. The sync() report names the same gap in report.gap (null when there is none). For a server_limit gap, report.gap.path names the read that counted: table_walk, index_lookup or token_diff (B3233). The key is absent when the server names no path. The path is not stored, so freshness().gap has no path. To get a complete copy again, call await db.sync('orders', { full: true }). That sync fills the table from SINCE START, replaces the local rows and clears the gap.

never_synced and an empty table are different answers. A table that has never synced may hold rows you wrote yourself, and they speak only for themselves.

After a restart of the process or a reload of the page, no live watch keeps a table current. So the open of the sync session sets each current subscription to offline_stale, with its token (B3824). Until you call sync() for the table, freshness() answers offline_stale, and a query that the local rows cover answers the old rows with the state offline_stale and makes no server call. It never answers covered with rows that the server changed while the page was closed. A subscription that is filling stays filling.

Is a local query complete? ​

freshness() describes a whole table. queryCoverage(sql) describes one query. It tells you if the local rows hold every row that the query can match:

js
await db.queryCoverage("SELECT * FROM orders WHERE customer = 7 AND status = 'open'");
// { state: 'covered' }        the current subscriptions hold every row the query matches
// { state: 'offline_stale' }  the subscriptions hold every row, as of the last sync; the copy can be behind
// { state: 'not_covered' }    no proof: the server can have rows that the local answer does not show

query(sql) runs the query and answers this state as its coverage; see the next section.

The check is SAFE. covered is a proof. When the check is not sure, the answer is not_covered. A not_covered answer does not mean that rows are missing. It means only that the check cannot prove that no row is missing.

The check compares the WHERE of the query with the predicates of the subscriptions of the table. It understands these parts:

  • column op literal, with =, <, <=, > and >=. The parser starts each compare with a column, so 7 = customer does not parse.
  • column BETWEEN low AND high and column IN (literal, …).
  • AND and OR of these parts.

Any other part is UNKNOWN: NOT, <>, IS NULL, LIKE, a function call, a compare of two columns and a subquery. An unknown part of the query is ignored, which makes the query wider; so a proof for the wider query is a proof for the real one. A subscription predicate with an unknown part does not count. A literal compares only with a literal of the same kind; for example, a number and a text are never compared.

The check works on a SELECT from ONE table, with no subquery. ORDER BY, LIMIT, GROUP BY and aggregates are correct over a covered WHERE, because every row that the WHERE matches is local. A join has its own check: see query below. Any other statement is not_covered.

The states:

  • covered: the current subscriptions of the table cover the query.
  • offline_stale: the query is covered only when the offline_stale subscriptions count too.
  • not_covered: no set of subscriptions covers the query. A subscription that is still filling never counts.

A subscription with no predicate (sync('orders')) covers every query of its table.

A subscription whose WHERE is the same as the WHERE of the query covers it, also when the WHERE holds an unknown part. Two texts are the same WHERE when they parse into the same predicate: spaces, the case of keywords and extra parentheses do not count.

query — a query that gets its rows from the server ​

query(sql) runs one SELECT and answers the sql() result plus its coverage:

js
const result = await db.query('SELECT * FROM orders WHERE customer = 7');
// { type: 'rows', columns, types, data, count, schemaId: null, coverage: 'covered', gap: null }
  • Covered. When the local rows cover the query, query() reads them and sends nothing.
  • Not covered because of a gap. A gap of the table (see gapped above) is a gap of every subscription of the table: one live watch carries them all. When only the gap keeps the query from covered, query() sends nothing. It answers the local rows as not_covered, and gap holds the gap record that freshness() shows. A forward cannot repair a gap; a full sync can: await db.sync('orders', { full: true }). In every other answer gap is null.
  • Not covered, online. When a sync session is open and online, query() FORWARDS the query. The rules of the next section decide what the forward does:
    • The FIRST run of a query is a ONE-TIME answer. The server runs the SELECT one time. The answer is covered: it is complete as of the server commit that answered it. The client keeps no subscription and stores no row of the answer.
    • The SECOND run of the same WHERE within 300 seconds of the first run makes it LIVE. Its WHERE becomes a subscription of the table on the server. The live watch of the table brings the rows of the query into the local table, and keeps them current after that. The answer is covered. A later run reads locally and sends nothing. A change on the server that lands while the rows arrive is not lost: it comes in the next change of the live watch.
    • A table with local writes that are not pushed yet gets no one-time answer: a server answer cannot hold those writes. The first run makes the query live at once, and the local run holds the pending writes.
  • Not covered, offline. With no sync session, or when the session is offline, query() answers the local rows as not_covered. Nothing is forwarded. query() never opens a session.

The forward refuses a query that a subscription cannot keep exact. The promise rejects with the reason and the issue that will lift the limit:

The query hasThe forward
a subqueryrefuses: a subscription cannot keep it exact
an ORDER BY of an expression with LIMIT, OFFSET or TOP (for example <->)refuses: not a window query
AS OFrefuses: a subscription follows the newest data
a statement that is not a SELECTrefuses: run it with sql()

ORDER BY is accepted. A WHERE with LIKE, IS NULL, <> or NOT is accepted too: on its second run it becomes a subscription, and a later run is covered by its own subscription.

An aggregate query (COUNT, SUM, GROUP BY, HAVING) is never a subscription:

  • When the subscriptions cover its WHERE, query() answers it locally and sends nothing.
  • When they do not, and the session is online, EVERY run is a one-time server answer. The answer is covered as of the server commit that answered it. The client keeps no subscription and stores no row, so a COUNT(*) never pulls the rows under it.
  • Offline, query() answers the local value as not_covered.

A join query (INNER JOIN or LEFT JOIN, with = in each ON) becomes live on its second run, through its KEYS. The client never syncs a whole joined table:

  • The first table (the driver) keeps its part of the WHERE as a normal query subscription. In a join of INNER JOINs only, the driver is the first table with such a part.
  • Each other table keeps ONE key row: column IN (keys). The keys come from the local rows of the tables before it. An INNER JOIN table adds its own part of the WHERE; a LEFT JOIN table adds none.
  • After each change from the server, the client moves the key rows to the new keys.
  • Each run computes the coverage from the local rows. A key that is new on the device gives not_covered at once, never covered with rows missing.
  • The first run is a one-time answer, and each key row counts as one predicate of the client budget.

These joins are a one-time server answer on EVERY run: a RIGHT JOIN, a self-join, a join with LIMIT or OFFSET, and a join that needs more than 1024 keys.

When a live join cannot move its key rows (more than 1024 keys, or a move above the client budget), the client removes the key rows of the join and continues. The live changes of the other tables do not fail, and the next run of the join is a one-time server answer. The query subscription of the driver stays, so a later run does not fill the driver again. It counts in the client budget. One exception: when the server refuses the removal of a key row, or the connection breaks during it, the live change of that table fails with that error. The key rows then stay, so the next run of the join tries the live step again, one time for each run.

The limit of covered for a join. Two tables keep two tokens. The local join is complete for each table at its own token, but the two tails do not move together. Between two changes, the join can show one table after a change and the other table before it. That state can be a state that never existed on the server. For example, one server transaction deletes customer 1 and its invoices. When the customer change arrives first, a LEFT JOIN can show an invoice of customer 1 with a NULL customer, and the answer is still covered. The next change of the other table closes the window.

js
const r = await db.query(
  "SELECT o.id, c.name FROM orders o JOIN customers c ON o.customer = c.id WHERE o.status = 'open'");

A window query (ORDER BY … LIMIT n [OFFSET m], or TOP n) is forwarded as a WINDOW (B2236), not as a WHERE subscription: a WHERE subscription is wider than the query. The one-time answer of the first run does not apply to a window: the first forward of a window makes it live. A window counts as one predicate of the client budget (see below).

js
const top = await db.query('SELECT TOP 10 * FROM invoice ORDER BY amount');
// coverage: 'covered'; a second run reads locally and sends nothing
  • The window subscribes to a RANGE of the table: the WHERE, then the order key up to a bound. The bound is the key of row 2 × (n + m) of the server answer. The live watch keeps every row of the range on the device.
  • After each change of the table, the client counts the local rows of the range. With fewer than n + m rows, it runs the query on the server again and moves the bound. A row that a server delete removes from the window is replaced by the next row, with no new forward.
  • A window query is covered only by its OWN window: the same table, WHERE, ORDER BY, n and m. The window must also be current, the table must have no gap, and the local range must hold at least n + m rows (or the range has no bound). Else the answer is not_covered. The answer is never covered with rows missing.
  • A local delete that is not pushed yet can make a window not_covered until the next change from the server.
  • When two rows have the same order key at the end of the window, the local order of the two rows can differ from the server order. Both orders are correct for the query.

When the forward makes a query live, it refuses a table that holds local rows that no subscription claims. The first fill of a new subscription replaces the local table, so it would erase those rows. Sync the table with sync() first, or empty it.

The forward waits until the session is free: one session runs one operation at a time. sql() does not change: it reads the local rows and answers no coverage.

The schema version of a table in the embedded engine ​

A rows result of sql() and query() has tableVersions: an array of [table, version], one pair for each table that the statement read. A watch_fill, a watch_tail and a watch_data frame of a connection have schemaVersion. The names are the names of @kinedb/client.

js
const r = await db.sql('SELECT * FROM orders');
// r.tableVersions  →  [['orders', 3]]
  • The version is the stamp of the last DDL on the table in this database. It is a Number.
  • The version changes at each DDL of the table, also a GRANT or a change of a policy. It never changes at a row write.
  • 0 or an absent value means that the version is not known. tableVersions is [] then.
  • The version of an embedded database is not the version of a server. The two counters are separate. Do not compare an embedded value with a server value of the same table.

How the client keeps its subscriptions small ​

Each live subscription costs the server a predicate and costs the device rows. The client follows three rules, so that the set of subscriptions stays small (B2205):

  • Live only on reuse. The first run of a WHERE is a one-time answer. Only a second run within 300 seconds makes it live. The client counts the runs in the local table kinedb_sync_query_runs, so a restart keeps the count.
  • Merge by containment, never wider. A query that a held subscription contains adds no subscription: for example, WHERE amount > 100 adds nothing when WHERE amount > 50 is held. A new live subscription that contains held query subscriptions replaces them: the client adds the new one, then drops each contained one. The client NEVER builds a wider predicate to merge two subscriptions. For example, it never merges LIKE 'a%' and LIKE 'ab%' into LIKE '%': that would sync the whole table.
  • A client budget. The budget limits the predicates and the local rows, for each table and for the client:
LimitDefault
predicates for each table64
predicates for the client512
local rows for each table1 000 000
local rows for the client5 000 000

A predicate is one subscription row: the "" row of sync(), the row of a live query or the row of a window. A window counts as one predicate, also while it moves its bound. A step that adds one subscription and drops d contained ones counts 1 - d. The row count is the local rows of the synced tables, plus the rows of the one-time answer of the query.

A query that would become live above the budget REJECTS. The error names the limit and its value. No subscription changes. The client NEVER replaces the refused subscription with a wider one. Run the query with sql() for the local rows, or raise the budget:

js
await db.setQueryBudget({
  perTablePredicates: 128,
  predicates: 1024,
  perTableRows: 2_000_000,
  rows: 10_000_000,
});

Each of the four fields must be a non-negative integer; the row fields can be a bigint. The budget applies to the open sync session and to each later session of this database. The Rust client has the same call: SyncClient::set_budget(kinedb_sync::Budget { … }), with Budget::DEFAULT for the defaults.

The server has its own limit, cluster.watch.max_predicates_per_client (default 100 000). The client budget is far below it, so the client refuses first, with a clear reason.

Where the sync state lives ​

The sync state is in ordinary tables of your local database, so it commits, reopens and travels with the rows. The table kinedb_sync_subscriptions holds one row for each subscription, keyed by the table and the subscription id. sync() uses the id ""; a forwarded query uses q_ and 16 hex digits made from the table and its WHERE. A window (a forwarded window query, or sync_window of the native client, B2202) uses w_ and the 16 hex digits of its query. Each row holds the predicate, the token, the state and last_used, the time of the last sync write in milliseconds. last_used is for the subscription leases of B2206.

One table can hold the "" row of sync(), the rows of forwarded queries and SEVERAL windows, each with its own row (B2204, B2236). One live watch carries all of them, and every row of the table holds one token: a fill, a change and a failure move every row together. A gap of the table is a gap of each row.

The table kinedb_sync_rights holds the rights stamp of the last fill of each table (B2681). The stamp is a digest of your roles and of the grants of the table. Each sync() sends it back. When your roles or the grants of the table changed since the last fill, the server sends the whole table again, and the sync replaces the local copy. So a row that you lost the right to read leaves the device, and a row that you gained arrives, although no row changed. The device keeps its rows until that next sync(). A live sync sees the change while it runs (B2682): the server sends the whole table again as a live diff, and the device replaces its copy. A GRANT ROLE, a REVOKE ROLE, and a GRANT or a REVOKE on the table (B2687) arrive at once, also with no change of the table. A live diff does not write kinedb_sync_rights, so the next sync() after such a change fills the table once more. The name is reserved.

The table kinedb_sync_query_runs holds the run count of each query that had a one-time answer, keyed by the table and the q_ id. The client creates it when it needs it. A row goes away when its query becomes live, or at a later one-time answer after its 300 seconds end. The name is reserved too.

A database from an older version holds the table kinedb_sync_coverage instead. The first open of a sync session moves its rows to the new table and keeps their sync state. Your data and your freshness do not change. Both names are reserved: sync() refuses a table with either name.

When a sync fails ​

A failed sync() rejects with a KineDBError. Read its retryable field to decide what to do next. A failed sync does not claim a complete copy: it keeps the last complete token, freshness() never answers complete_as_of for the fill that failed, and the next sync() resumes from that token.

When the sync failed before it wrote rows, the copy is still complete as of that token, and freshness() answers offline_stale. When it failed after it wrote a part of its rows, the copy matches no commit. For example, a row that moved to a new key can be absent from both keys. freshness() then answers filling with the kept token, and queryCoverage(sql) answers not_covered for each query of that table until a sync ends.

One case is different. Sometimes the server answers a sync with a FULL fill, for example when your token is too old. A full fill clears the local table before it writes the first rows. When it fails after that, no token describes your copy. freshness() then answers filling with an empty token, and the next sync() fills the whole table again.

When the server changes the columns of a table ​

The server can change the columns of a synced table with ALTER TABLE … ADD COLUMN, DROP COLUMN or RENAME COLUMN. The next sync() applies the same change to the local table, in place:

  • The local rows stay. A local write that push() has not sent yet also stays visible.
  • After a RENAME COLUMN, each write in the push queue uses the new column name, so the server applies it. The values in the write do not change.
  • An added column reads NULL in the old rows, as on the server. A dropped column leaves the local table, with its values.
  • The sync answers OK, and it does not fill the whole table again.

The sync makes the local table again from the server schema in these cases: the server dropped the table and created it with other columns, the primary key changed, a column has another type, or the server is older than the device and does not send the column identity. The next sync() finds that the rows from the server do not fit the local table:

  • First, every subscription of that table goes to filling with an empty token. A query of the table then answers not_covered until the sync ends.
  • Then the sync drops the local table, creates it with the server columns, and fills the whole table again. The sync answers OK, and freshness() answers complete_as_of.
  • A local table with EXTRA columns that the server does not send is not changed.

The local DROP TABLE and CREATE TABLE do not go to the push queue, so they never change the server. In this case, a local write to the table that push() has not sent yet stays in the queue, with the old columns. The sync does not change it.

When the server dropped the table and did not create it again, the sync fails with the error the table has no schema on this node. The local rows stay readable, but freshness() answers filling with an empty token, and each query of the table answers not_covered.

retryable is true in two cases:

  • The server refused the fill for a short time. For example, the node you reached does not serve the table at the moment, or it has no read lease yet. The text then ends with retry shortly against a current member, or the server asked for a backoff. Call sync() again after a short wait.
  • The socket closed. With the default reconnect option, the database opens a new session by itself and resumes the live tables (see "When the connection closes"). With reconnect: false, pass the url and the credential again to open a new session.

retryable is false for every other failure: a denied permission, an unknown table, a fill the local database could not apply. A retry gives the same answer, so do not loop on it. The server never retries for you. The database retries only a lost socket (the automatic reconnect); the retry of a refused call is always your call.

What the sync covers ​

sync() works on a TABLE. The six methods — sync, push, freshness, refused, discardRefused and queryCoverage — are the whole surface, and any of them may carry the url and ONE credential shape to open the session when there is none. The four shapes are the ones connect() takes: { url, user, password }, { url, token }, { url, user, key } (see "Log in with a key pair" above) and { url, anonymous: true }. query() takes no credential: it uses the session that one of the six opened, or reads locally.

The sync uses a connection of its own, separate from the one connect() answers, so a subscription you registered with connect() keeps running while a sync is in flight.

Whole rows only: there is no projection over a sync, because a local row holding some of its columns could not answer an ordinary SELECT *.

connect() is a promise, like sql(), so await it and catch it the same way you catch a statement. It rejects when you leave the credentials out, so read the four shapes above and name the one you want.

When a remote statement fails ​

A failed sql() on the connection that connect() answers rejects with a KineDBError. Its retryable field is true when the server marked the error as retryable. Examples are not the leader, retry shortly and a split, merge or transfer that holds the writes of the shard. Every other error has retryable set to false, and a retry gives the same answer.

A write hold also carries response.refusal, for example { kind: 'write_hold', retry_ms: 40 }. The connection retries a refusal of the kind write_hold or not_ready for you (B2298). The server sent it before the write took effect, so nothing was written. The connection sends the statement again after retry_ms, for up to 20 s from the first send. A transaction() sends the WHOLE transaction again. After 20 s, the call rejects with the last refusal. The connection does not retry any other retryable error: send the statement again after a short wait, or give up.

retry: false turns off the hold retry, for the connection or for one transaction:

js
const conn = await db.connect(url, { user, password }, { retry: false });
await conn.transaction(statements, { retry: false });

See it run — the five demos ​

Every kinedb node serves five demonstration pages at /ui/demos/, and the console links to them under Help → Demos. They run this package in the browser: the engine is WebAssembly inside a Web Worker. Each page keeps its database in memory, so a reload starts fresh and the pages write nothing to your disk.

pagewhat it shows
Performancethe local engine and a server, given the same seed and the same mixed read/write workload, side by side
Live Watchconnect() and watch() — every INSERT, UPDATE and DELETE on a server table, as it lands
Sync Playgroundtwo local databases, an offline switch each, sync() and push(), and the writes the server refused
Canvas Synctwo people drawing offline, and the strokes merging when both come back
Filesa file and its record saved in one local transaction; the link drops halfway through the upload, and the next push sends only the chunks the server does not hold; a lost reply, and the next push answered replayed, with one row on the server

Read their source next to your own code: it is web/demos/ in the repository. Each page keeps every statement it issues in one driver.js beside it, and a test drives that file against this engine, so the SQL you read on a page is SQL that is proved to work.

Sign in once, not on every page ​

Every one of the five pages can reach a server, so each one needs a credential. You give it once:

  • Sign in on the console at /ui/. Every demo page then connects with that session and shows NO login form. The console and the pages are the same origin, so a page reads the session the console already holds.
  • On a server that serves anonymously, a page needs no login at all, and it shows no form either.
  • A page shows its own login form only when it has neither. It shows the form ONCE, with a link back to the console.

This removes a password from five pages, and it adds no exposure: the session already lives in this origin, and the page reads it rather than asking you for the password again.

What the Performance page measures ​

Both columns run the same statements, one document per statement: a document collection takes no multi-row INSERT, so a seed of 50,000 documents is 50,000 INSERTs on each side. The two columns differ only in where the loop runs, and each one runs it as close to its own engine as it can:

  • Local runs the whole loop inside the Web Worker that holds the engine, so the figure is the engine's and not the cost of talking to the worker.
  • Server sends each transaction's INSERTs without waiting between them and awaits the batch, so the figure is the server's and not the cost of the round trip. Statements sent this way still apply in the order they were sent.
  • The mixed read/write lane on the Server side keeps many statements in flight on each connection. The server runs the writes of one connection side by side when they touch different rows, so they share one replicated batch. A read of one collection runs beside the writes too, unless an earlier write can change what it reads; then it waits for that write. A write to a row that an earlier write still holds, or that an earlier read still reads, waits for that statement. The replies come back in the order the statements were sent, and the result is the result of a run one statement at a time.

If you want to see what a slow link does to the Server column, put one in front of a node: bun web/demos/perf/latency-proxy.mjs 4899 4820 1.475 adds a 2.95 ms round trip, and the page can then be pointed at ws://127.0.0.1:4899/ws.

What the engine runs ​

The embedded engine takes the same SQL as the server: tables, documents, key-value, vectors and transactions. See the SQL reference and kd, the scripting language.

A local db.sql('WATCH t') and db.sql('UNWATCH t') are refused with an error (B2198). A watch on the embedded database is not available yet (B2196). To receive the changes of a server table, use connect() and watch(), or sync() with onChange.

sql(text, params) takes parameters as the client libraries do: await db.sql('SELECT id FROM orders WHERE state = $1', [state]). The engine binds each value into the statement with the binder of the server, also on a synced table and on a connect() connection. An array of scalars of one kind is an ARRAY value, with the rules of the JavaScript client. See Parameters.

Transactions in the embedded engine ​

A transaction is ONE call: transaction(items, { token }) takes the complete list and writes all of it, or nothing of it. The same call works on @kinedb/client. There is no open transaction: sql('BEGIN'), sql('COMMIT') and sql('ROLLBACK') are refused with an error that names transaction().

js
await db.transaction([
  ['INSERT INTO orders (id, item) VALUES ($1, $2)', [7, 'pen']],
  { putFile: ['files', 'receipt-7.pdf', bytes] },
  'UPDATE stock SET n = n - 1 WHERE item = \'pen\'',
], { token: 'order-7' });
  • An item is a statement (a string, [sql] or [sql, params]) or a file ({ putFile: [collection, name, data] }, the arguments of putFile()).
  • The answer is { type: 'created' }. A call with file items also answers files: the record of each file item, in list order, the shape that putFile() answers.
  • When one item fails, the call throws its error, and nothing of the call is written.
  • A write sees the earlier writes of the same call. An UPDATE of a row that an earlier INSERT of the call wrote finds that row.
  • A SELECT in the list reads the committed rows only, and gives no rows back.
  • DDL cannot be an item.
  • token makes a retry safe: a call whose token committed before applies nothing and answers created.

Other readers see none of the writes before the call commits. When another writer commits during the call, the commit runs the statements again against the newest rows.

A file joins the transaction too. A file item stores the file together with the rows of the call. When the call fails, it stores neither the file nor the rows. See Files.

A transaction on a SYNCED table stays one transaction on the server. Each write of the call records its op-log entry inside the transaction, so a call that fails records no entry. push() sends the entries of one transaction in one frame, and the server applies all of them or none of them. When the server refuses one entry of the transaction, it refuses every entry of it, and refused() shows each one. A write to a synced table records its entry also when no sync() is open, for example after a reload.

Files in the embedded engine ​

putFile and getFile have the shape of the JavaScript client. The engine cuts the file into the same chunks as the server (BLAKE3, at most 128 KiB each). A file goes into a table with a TEXT primary key (the file name is the key), or into a collection.

js
await db.sql('CREATE TABLE videos (name TEXT PRIMARY KEY)');

// A Uint8Array, an ArrayBuffer, a Blob, a ReadableStream<Uint8Array>,
// or an (async) iterable of Uint8Array.
const put = await db.putFile('videos', 'talk.mp4', file.stream());
// put.size, put.hash (BLAKE3 of the file, hex), put.chunks (hex, in file order)

const stream = await db.getFileStream('videos', 'talk.mp4');   // null when missing
for await (const chunk of stream) {
  // one checked chunk per read
}

const bytes = await db.getFile('videos', 'talk.mp4');   // the whole file, a Uint8Array

A file moves through the engine one piece at a time. So a file larger than the memory of the device can store: a 256 MiB file keeps the memory of the engine near 16 MiB. putFile never joins the pieces. When the source fails, putFile rejects and stores nothing.

getFileStream checks each chunk against its hash. The read of the last chunk also checks the size and the hash of the whole file. When a check fails, the stream ends with an error, never as a complete file. getFile does the same checks, and a missing file throws file not found.

Which chunks the server holds ​

A push that stops half way leaves some chunks of a file on the server. To show how much of the file is there, ask the server. heldChunks on a connection from connect() takes the chunk hashes that putFile gives, and gives one flag for each hash, in the same order:

js
const conn = await db.connect(url, { user, password });
const held = await conn.heldChunks('videos', 'talk.mp4', put.chunks);
// [true, true, false, …] — true when the server holds that chunk
const onServer = held.filter(Boolean).length;

This is the question that the push asks before it sends a chunk (FindHeldChunks). A long list goes in parts of 16,384 hashes. The answer is a hint: a user who cannot read the whole table gets false for every hash, and a user who cannot write the table gets the server's refusal. A hash that is not 64 hex digits throws, and then nothing goes to the server. After a reload, cut the stored file again in an in-memory database (putFile there gives the same chunks). The JavaScript client has the same call.

The engine sizes its caches from the memoryBudget option, in bytes. The default is 32 MiB and the minimum is 4 MiB:

js
const db = new KineDB('media', { memoryBudget: 64 * 1024 * 1024 });

The option applies to an in-memory database too. With no memoryBudget, an in-memory database keeps every row. With a budget, the rows may use the budget less the tree-node cache and less 1/3 for the work of the statements. A write past that share fails with an error that names the budget, and the rows committed before it stay readable:

js
const db = new KineDB('scratch', { memory: true, memoryBudget: 16 * 1024 * 1024 });

To reach a running kinedb server instead, use the JavaScript client or the Python client.