JavaScript / TypeScript — gRPC Driver

@arcadedb/driver-grpc is ArcadeDB’s TypeScript/JavaScript gRPC client, generated from the server’s Protobuf contract. It wraps the generated Connect client in a small facade for streaming queries, streaming inserts and transactions.

Install

npm install @arcadedb/driver-grpc

The gRPC server is a plugin, and it is not started by default.

Register it in the server’s plugin list before connecting:

-Darcadedb.server.plugins=GRPC:com.arcadedb.server.grpc.GrpcServerPlugin

Once registered, it listens on port 50051. See the gRPC API reference for the full option list, including TLS and message-size settings.

Runtime targets: Node, Bun, Deno — not browsers

The package requires Node.js 20 or later, and it ships ESM only — there is no CommonJS build and no require() entry point. Import it with import, not require.

Bun and Deno are intended targets for this driver, but only Node is what the arcadedb-drivers repository’s continuous integration actually exercises against a live server. Treat Bun and Deno as likely to work rather than as verified.

There is no browser build, and there cannot be one until the server changes. ArcadeDB’s GrpcServerPlugin is plain grpc-java over HTTP/2, with no gRPC-Web handler and no Connect protocol in front of it, so a browser cannot speak this driver’s wire format at all. Use the HTTP driver there.

Connect and query

import { createClient, passwordAuth } from "@arcadedb/driver-grpc";

const grpc = createClient({
  baseUrl: "http://localhost:50051",
  auth: passwordAuth("root", "playwithdata", "mydb"),
  insecure: true,
});

const response = await grpc.raw.executeQuery({
  database: "mydb",
  query: "SELECT FROM Person WHERE age > 21",
  language: "sql",
});
console.log(response.results.flatMap((r) => r.records.map((rec) => [rec.rid, rec.properties])));
[
  [ '#1:0', { name: [Object], age: [Object], '@rid': [Object], '@type': [Object], '@cat': [Object] } ],
  [ '#1:1', { name: [Object], age: [Object], '@rid': [Object], '@type': [Object], '@cat': [Object] } ]
]

Node’s default console.log collapses nested objects two levels deep, and each property value here is itself a nested message — that is the summarizing behavior of util.inspect, not evidence of anything wrong. See below for what those objects actually contain and how to unwrap them.

The unary executeQuery has a row ceiling, and it fails rather than truncates.

ExecuteQuery builds its whole answer into one gRPC message, so the server caps it: server.grpcQueryMaxResultRows (100000 by default) is a hard ceiling, and a result that would exceed it fails the call with RESOURCE_EXHAUSTED instead of coming back short. Unlike the HTTP drivers there is no truncated flag to check — a capped query does not return at all.

Streaming is the answer for result sets that large: streamQuery emits incrementally and is bounded instead by server.grpcStreamMaxMaterializedRows (and only in MATERIALIZE_ALL mode) and by server.grpcStreamWriteTimeoutMs. See Settings for all three, and the gRPC API reference for the service itself.

raw is the generated Connect client for ArcadeDbService, the same one you get from createClient(ArcadeDbService, transport) if you built the transport yourself — every RPC in the data-plane contract is callable through it exactly as shown above with executeQuery. createClient from this package adds streamQuery, insertStream and transaction on top, covering the RPCs the generated client alone handles awkwardly: consuming a server-side stream, feeding a client-side stream, and pairing begin/commit/rollback into one call.

record.properties and the GrpcValue wrapper

Every value in a record’s properties map comes back as a GrpcValue object, not a plain JavaScript scalar. Printed at full depth, the name and age fields above look like this:

name: {
  '$typeName': 'com.arcadedb.grpc.GrpcValue',
  kind: { case: 'stringValue', value: 'Alice' },
  logicalType: ''
}
age: {
  '$typeName': 'com.arcadedb.grpc.GrpcValue',
  kind: { case: 'int32Value', value: 30 },
  logicalType: ''
}
'@rid': {
  '$typeName': 'com.arcadedb.grpc.GrpcValue',
  kind: {
    case: 'linkValue',
    value: { '$typeName': 'com.arcadedb.grpc.GrpcLink', rid: '#1:0', type: '' }
  },
  logicalType: 'rid'
}

GrpcValue.kind is this package’s Connect-ES rendering of the contract’s oneof field: a discriminated union with case naming which alternative is set and value holding it directly — there is no per-type field name to guess, unlike the generated Python client’s int32_value/string_value/link_value fields for the same oneof. Unwrap it by branching on kind.case:

function unwrap(value) {
  if (value.kind.case === "linkValue") return value.kind.value.rid;
  return value.kind.value;
}

for (const result of response.results) {
  for (const rec of result.records) {
    const props = Object.fromEntries(Object.entries(rec.properties).map(([k, v]) => [k, unwrap(v)]));
    console.log(rec.rid, props);
  }
}
#1:0 { name: 'Alice', age: 30, '@rid': '#1:0', '@type': 'Person', '@cat': 'v' }
#1:1 { name: 'Bob', age: 25, '@rid': '#1:1', '@type': 'Person', '@cat': 'v' }

linkValue is the one case that does not unwrap to a scalar directly — its value is itself a GrpcLink message, so reach into .rid for the record ID string, as unwrap does above. There is no library function that performs this unwrapping; it is a plain discriminated-union read, shown here for reference.

Authentication and the insecure guard

bearerAuth(token) sets authorization: Bearer <token> on every outgoing call’s metadata. passwordAuth(user, password, database?) sets x-arcade-user, x-arcade-password and (when given) x-arcade-database instead. Both are passed once, as the auth option to createClient — there is no per-call credential to attach separately.

The insecure-channel guard, and its limit.

passwordAuth sends the password as plaintext call metadata. createClient refuses to pair it with a non-TLS http:// baseUrl unless you pass insecure: true explicitly:

import { createClient, passwordAuth } from "@arcadedb/driver-grpc";

try {
  createClient({ baseUrl: "http://localhost:50051", auth: passwordAuth("root", "playwithdata") });
} catch (err) {
  console.log("guard fired:", err.message);
}
guard fired: createClient: refusing to send a plaintext password over insecure baseUrl "http://localhost:50051". Use an https:// baseUrl, switch to bearerAuth, or pass insecure: true to opt in explicitly.

This is why the connect example above needs insecure: true at all: pairing passwordAuth with an http:// baseUrl is exactly what this guard exists to catch.

The check has a real limit, stated plainly: it recognizes only the exact interceptor value passwordAuth returns, marked internally with a symbol property. Wrap that interceptor in your own logging or retry interceptor and the value createClient sees is a new function without the marker — the refusal is silently skipped, and the plaintext password still goes out over http://. The guard protects the direct, undecorated case; it is not a general plaintext-secret scanner.

Streaming queries and retrievalMode

import { createClient, passwordAuth, StreamQueryRequest_RetrievalMode } from "@arcadedb/driver-grpc";

const grpc = createClient({
  baseUrl: "http://localhost:50051",
  auth: passwordAuth("root", "playwithdata", "mydb"),
  insecure: true,
});

for await (const row of grpc.streamQuery({
  database: "mydb",
  query: "SELECT FROM Person",
  language: "sql",
  retrievalMode: StreamQueryRequest_RetrievalMode.CURSOR,
  batchSize: 500,
})) {
  console.log(row.rid, row.properties);
}
#1:0 {
  name: {
    '$typeName': 'com.arcadedb.grpc.GrpcValue',
    kind: { case: 'stringValue', value: 'Alice' },
    logicalType: ''
  },
  age: {
    '$typeName': 'com.arcadedb.grpc.GrpcValue',
    kind: { case: 'int32Value', value: 30 },
    logicalType: ''
  },
  '@cat': {
    '$typeName': 'com.arcadedb.grpc.GrpcValue',
    kind: { case: 'stringValue', value: 'v' },
    logicalType: ''
  }
}
#1:1 {
  name: {
    '$typeName': 'com.arcadedb.grpc.GrpcValue',
    kind: { case: 'stringValue', value: 'Bob' },
    logicalType: ''
  },
  age: {
    '$typeName': 'com.arcadedb.grpc.GrpcValue',
    kind: { case: 'int32Value', value: 25 },
    logicalType: ''
  },
  '@cat': {
    '$typeName': 'com.arcadedb.grpc.GrpcValue',
    kind: { case: 'stringValue', value: 'v' },
    logicalType: ''
  }
}

streamQuery flattens the server’s stream of row batches into one record at a time and does nothing else — each properties entry is still a GrpcValue needing the same unwrap shown above. Both retrievalMode and batchSize are deliberately the caller’s own choice; the wrapper picks no default for either. There are three retrieval modes:

  • CURSOR — the proto default (value 0). The server runs the query once and streams rows as you iterate.

  • MATERIALIZE_ALL — the server runs the query to completion first, then batches the already-materialized result set back to you.

  • PAGED — the client re-issues the query with LIMIT/SKIP per batch.

Leaving both fields out does not pick CURSOR and a sensible batch size as a considered choice — it sends whatever protobuf’s zero values happen to be for those fields (0, which is CURSOR, and 0 rows per batch). Set both explicitly, as above.

Streaming inserts

insertStream owns the envelope bookkeeping the client-streaming InsertStream RPC needs — one session id for the stream, an incrementing chunk sequence, database on the first chunk, last on the final one — and leaves how rows are batched to you.

InsertStreamRequest.transaction is forwarded but not honored.

The field exists because the .proto declares it, and the driver sets it on every chunk exactly as you give it. On server 26.9.1 and earlier it buys nothing: the server ignores the TransactionContext on InsertStream entirely (ArcadeData/arcadedb#6607), builds its own insert context, and commits on its own. A batch you believe is atomic is not, and nothing errors to tell you so. That is also why insertStream is absent from the transaction handle below. Do not rely on it until #6607 lands.

Transactions

import { createClient, passwordAuth } from "@arcadedb/driver-grpc";

const grpc = createClient({
  baseUrl: "http://localhost:50051",
  auth: passwordAuth("root", "playwithdata", "mydb"),
  insecure: true,
});

const total = await grpc.transaction("mydb", async (tx) => {
  await tx.executeCommand({
    command: "INSERT INTO Account SET balance = 100, owner = 'grpc-demo-js'",
    language: "sql",
  });
  const response = await tx.executeQuery({
    query: "SELECT sum(balance) AS total FROM Account WHERE owner = 'grpc-demo-js'",
    language: "sql",
  });
  return response.results[0].records[0].properties.total.kind.value;
});
console.log("total:", total);
total: 100

transaction(database, fn) begins a server-side transaction, hands fn a TransactionHandle, and ends the transaction on both paths: it commits when fn resolves and returns the value fn returned, and rolls back and re-throws the original error when fn throws or rejects. A rollback that fails on that path is attached as cause on the original error rather than replacing it, and a failing commit issues a best-effort rollback first so the server-side session is released. A commitTransaction that resolves with committed: false — what the server answers for a transaction it already reaped — throws rather than reporting a success whose writes were lost.

The handle exposes executeQuery, executeCommand, createRecord, updateRecord, deleteRecord, lookupByRid and streamQuery, each with the transaction’s database and transaction context bound in automatically, so you never set either yourself. Note the omissions: insertStream and bulkInsert are not on the handle, because the server does not run them inside your transaction at all — binding them there would silently lie.

The trap is the same one the HTTP driver has: calls made through the outer client while a transaction is open do not join it. Only calls through the handle take part.

Errors

Every RPC rejects with a Connect ConnectError on failure — this driver adds no error type of its own. err.code is a Connect Code and err.rawMessage is the server’s message. Both come from @connectrpc/connect, which the driver depends on; add it to your own dependencies to import it directly.

import { Code, ConnectError } from "@connectrpc/connect";
import { createClient, passwordAuth } from "@arcadedb/driver-grpc";

const grpc = createClient({
  baseUrl: "http://localhost:50051",
  auth: passwordAuth("root", "playwithdata", "mydb"),
  insecure: true,
});

try {
  await grpc.raw.executeQuery({ database: "mydb", query: "SELECT FROM NoSuchType", language: "sql" });
} catch (err) {
  if (err instanceof ConnectError) console.log(Code[err.code], err.rawMessage);
}
Internal Query execution failed: Type with name 'NoSuchType' was not found

The code you are most likely to meet first is Unauthenticated. A stock ArcadeDB gRPC server refuses anonymous calls, so a client built without auth is created happily and then fails on the first RPC:

import { Code, ConnectError } from "@connectrpc/connect";
import { createClient } from "@arcadedb/driver-grpc";

const anonymous = createClient({ baseUrl: "http://localhost:50051" });

try {
  await anonymous.raw.executeQuery({ database: "mydb", query: "SELECT FROM Person", language: "sql" });
} catch (err) {
  if (err instanceof ConnectError) console.log(err.code === Code.Unauthenticated, err.rawMessage);
}
true Authentication required

ResourceExhausted is the other code worth branching on: it is what a result set larger than the server’s row cap returns, as described above.

Next steps