scriva
v1.2.1
Published
TypeScript/JavaScript client for ScrivaDB — a file-based document database with a gRPC API
Downloads
171
Readme
ScrivaDB — TypeScript / JavaScript Client
Node.js 18+ gRPC client for ScrivaDB.
npm package: scriva
Requirements
- Node.js 18+
- TypeScript 5+ (optional — plain JavaScript works too)
- A running ScrivaDB server (
make runfrom the repo root)
Install
npm install scrivaBuild (from source)
cd clients/js
npm install
npm run build # compiles TypeScript → dist/Quick start
import { ScrivaDB } from 'scriva';
const db = new ScrivaDB('localhost', 5433, 'dev-key');
await db.createCollection('users');
const id = await db.insert('users', { name: 'Alice', age: 30, role: 'admin' });
const record = await db.findById('users', id);
console.log(record); // { id: '1', key: '', rev: '1', data: { name: 'Alice', age: 30, role: 'admin' }, ... }
const admins = await db.findAll('users', {
filter: { field: 'role', op: 'eq', value: 'admin' },
orderBy: [{ field: 'name' }],
});
await db.update('users', id, { name: 'Alice', age: 31, role: 'superadmin' });
await db.delete('users', id);
await db.dropCollection('users');
db.close();CommonJS also works:
const { ScrivaDB } = require('scriva');API reference
Constructor
// Plaintext (no TLS)
const db = new ScrivaDB(host: string, port: number, apiKey: string);
// TLS — verify server against a CA certificate PEM buffer
const db = new ScrivaDB(host, port, apiKey, tlsCaCert: Buffer);
// TLS — load CA certificate from file path
const db = ScrivaDB.fromTlsCertPath(host, port, apiKey, '/path/to/ca.crt');x-api-key is attached as gRPC metadata on every call automatically.
Collection management
const name: string = await db.createCollection('col');
const ok: boolean = await db.dropCollection('col');
const names: string[] = await db.listCollections();
// Optional per-collection default TTL (seconds) — records without their own TTL
// expire after this long. Persisted; overrides the server-wide default.
await db.createCollection('sessions', 3600);CRUD
// Insert one record — returns the assigned ID (string)
const id: string = await db.insert('col', { field: 'value' });
// Insert multiple records — returns IDs in insertion order
const ids: string[] = await db.insertMany('col', [
{ name: 'Alice' },
{ name: 'Bob' },
]);
// Find by ID
const record: DBRecord = await db.findById('col', id);
// Find by ID with a field projection (N2) — only these fields land in `data`;
// id, key and rev are always included.
const partial: DBRecord = await db.findById('col', id, { fields: ['name'] });
// Streaming find — use `for await`
for await (const record of db.find('col', { filter, limit, offset, orderBy })) {
console.log(record);
}
// Convenience: collect all results into an array
const results: DBRecord[] = await db.findAll('col', { filter });
// Update — returns the updated ID
const updatedId: string = await db.update('col', id, { field: 'new value' });
// Delete — returns true if record existed
const deleted: boolean = await db.delete('col', id);FindOptions accepts:
| Option | Type | Notes |
|---------------|-------------------|-------|
| filter | FilterInput | See Filter syntax. |
| limit | number | Max results, 0 = no limit. |
| offset | number | Skip N leading rows (prefer pageToken for large offsets). |
| orderBy | OrderByInput[] | Multi-field sort (N3): [{ field, desc? }, …], applied in order. |
| fields | string[] | Field projection (N2): only these top-level fields are returned in data. |
| pageToken | string | Keyset cursor (N3) — see Pagination. |
| orderByField, descending | string, boolean | Deprecated single-field sort; honoured only when orderBy is empty. |
Pagination (keyset cursor, N3)
findPage returns one page plus an opaque cursor for the next. Pass an ordering
and a limit, then feed the returned pageToken back on the next call — keep the
ordering, filter and limit identical across pages. An empty pageToken means the
last page was reached.
let token = '';
do {
const page = await db.findPage('col', {
orderBy: [{ field: 'age' }],
limit: 100,
pageToken: token,
});
for (const r of page.records) console.log(r.id);
token = page.pageToken;
} while (token);Each write takes an optional trailing ttlSeconds argument. When greater than
0 the record expires that many seconds from the write, overriding the
collection default:
await db.insert('col', { field: 'value' }, 60); // expires in 60s
await db.insertMany('col', [{ n: 1 }, { n: 2 }], 120); // whole batch in 120s
await db.update('col', id, { field: 'v' }, 30); // reset expiry to 30s
// ttlSeconds omitted (or 0): insert/insertMany apply the collection default;
// update leaves any existing deadline untouched (a plain update is sticky).DBRecord shape:
interface DBRecord {
id: string; // uint64 returned as string
key: string; // caller-supplied string key ('' if none)
rev: string; // per-record revision, '1' on insert, bumped per write
data: Record<string, unknown>;
date_added?: string;
date_modified?: string;
}Keyed CRUD, Upsert & compare-and-swap (N1)
Records can carry a caller-supplied string key and expose a monotonic revision for optimistic concurrency.
// Keyed insert — set a key on a plain insert. A key already held by a live
// record is rejected with a gRPC ALREADY_EXISTS error.
const id = await db.insert('col', { name: 'Alice' }, 0, 'user:alice');
// Upsert — insert under a key, or atomically replace if it already exists.
// Returns the resulting record (rev starts at '1', bumped on each replace).
const rec: DBRecord = await db.upsert('col', 'user:alice', { name: 'Alice', tier: 'pro' });
// Find by key — resolves to null when the key is absent (NOT_FOUND → null).
const found: DBRecord | null = await db.findByKey('col', 'user:alice');
const partial = await db.findByKey('col', 'user:alice', { fields: ['name'] });
// Update by key — overwrites the record, preserving the key. Returns the write
// acknowledgement with the new rev. Throws a gRPC NOT_FOUND if the key is absent.
const w: WriteResult = await db.updateByKey('col', 'user:alice', { name: 'Alice', tier: 'vip' });
console.log(w.rev);
// Delete by key — true if a record was removed, false when absent (NOT_FOUND → false).
const gone: boolean = await db.deleteByKey('col', 'user:alice');
// Compare-and-swap — apply only if the record's current rev matches. A stale
// rev (or a missing key) is a clean no-op: { swapped: false, record: null }.
const cur = await db.findByKey('col', 'user:alice');
const res: CasResult = await db.updateIfRev('col', 'user:alice', cur!.rev, { name: 'Alice', tier: 'vip' });
if (!res.swapped) console.log('lost the race — retry');Aggregations (N4)
Compute count and numeric aggregations (sum/avg/min/max) in the engine,
honouring the same filter as find, optionally grouped by a field.
// Count matching records.
const total: number = await db.count('col');
const admins: number = await db.count('col', { field: 'role', op: 'eq', value: 'admin' });
// Group-by with numeric aggregations. Returns one AggregateResult per group,
// in ascending group order.
const byRole: AggregateResult[] = await db.groupBy('col', 'role', {
field: 'age',
aggregations: ['sum', 'avg', 'min', 'max'],
});
for (const g of byRole) {
console.log(g.group, g.count, g.numeric ? { sum: g.sum, avg: g.avg, min: g.min, max: g.max } : {});
}
// Full form — filter + group + aggregations.
const results = await db.aggregate('col', {
filter: { field: 'active', op: 'eq', value: 'true' },
groupBy: 'status',
field: 'total',
aggregations: ['sum'],
});AggregateResult shape:
interface AggregateResult {
group: unknown; // group-by value (type-preserved); null for the ungrouped result
count: string; // records in the group (uint64 as string)
numeric: boolean; // true when the numeric aggregates below are meaningful
sum?: number; avg?: number; min?: number; max?: number;
}Secondary indexes
await db.ensureIndex('col', 'fieldName');
const ok: boolean = await db.dropIndex('col', 'fieldName');
const fields: string[] = await db.listIndexes('col');Once an index exists, findAll / find with a single eq filter on that field
uses the index automatically — no query hint needed.
Transactions
const txId: string = await db.beginTx('col');
const ok: boolean = await db.commitTx(txId);
const ok: boolean = await db.rollbackTx(txId);Watch (streaming change feed)
for await (const event of db.watch('col')) {
console.log(event.op, event.record.id, event.record.data);
// event.op: 'INSERTED' | 'UPDATED' | 'DELETED' | 'OVERFLOW'
// OVERFLOW means the server dropped events because this subscriber fell behind
// (no record is set) — resync by re-reading the collection.
}With an optional filter — only matching events are delivered:
for await (const event of db.watch('col', { field: 'role', op: 'eq', value: 'admin' })) {
// ...
}Break out of the for await loop to stop watching.
Stats
const s = await db.stats('col');
// s.collection, s.record_count, s.segment_count, s.dirty_entries, s.size_bytes (all strings)Maintenance
// Force a synchronous compaction pass — merges/deduplicates sealed segments and
// reclaims space from deleted or expired records. Resolves true on success.
const ok: boolean = await db.compact('col');Backup
// Stream a consistent gzip snapshot of the whole database straight to a file.
// Resolves with the number of bytes written; restore with `tar xzf backup.tar.gz`.
const bytes: number = await db.snapshotToFile('backup.tar.gz');
// Or consume the raw gzip byte chunks yourself (Snapshot is server-streaming):
for await (const chunk of db.snapshot()) {
// chunk: Buffer
}Lifecycle
db.close(); // shuts down the gRPC channelFilter syntax
Filters are plain JavaScript objects.
Field filter
{ field: 'age', op: 'gt', value: '30' }
{ field: 'name', op: 'contains', value: 'alice' }
{ field: 'email',op: 'regex', value: '.*@gmail\\.com' }AND composite
{
and: [
{ field: 'age', op: 'gte', value: '18' },
{ field: 'city', op: 'eq', value: 'Berlin' },
],
}OR composite
{
or: [
{ field: 'role', op: 'eq', value: 'admin' },
{ field: 'role', op: 'eq', value: 'superadmin' },
],
}Supported op values
| op | Meaning |
|------------|---------------------------|
| eq | equal |
| neq | not equal |
| gt | greater than |
| gte | greater than or equal |
| lt | less than |
| lte | less than or equal |
| contains | string contains (substring)|
| regex | regular expression match |
TLS
import * as fs from 'fs';
// From buffer
const db = new ScrivaDB('myserver.example.com', 5433, 'my-api-key',
fs.readFileSync('/path/to/ca.crt'));
// From path (convenience static factory)
const db = ScrivaDB.fromTlsCertPath('myserver.example.com', 5433, 'my-api-key',
'/path/to/ca.crt');When no CA cert is supplied the client connects over plaintext (insecure channel).
Running the examples
Start the server first:
# From repo root
make runThen in a separate terminal:
cd clients/js
# Install deps
npm install
# Basic CRUD example
npx ts-node examples/test_basic.ts
# Watch streaming example
npx ts-node examples/test_watch.tsUnix socket
Node.js can connect over the Unix domain socket for zero-overhead local connections:
import * as grpc from '@grpc/grpc-js';
import { ScrivaDB } from 'scriva';
// Pass the socket path as a grpc URI:
// 'unix:///tmp/scriva.sock'
// Use the internal constructor signature with a pre-built stub for advanced use.For the common case, TCP (localhost:5433) is sufficient. Unix socket support via
a custom channel address is available using @grpc/grpc-js directly.
