carbondb
v0.1.0
Published
CarbonDB for Node: services as ordinary backend code, with state that lives on every member's device
Readme
carbondb
Write a service the way you write an ordinary backend service, with named calls, SQL tables and handlers, and CarbonDB keeps its state on the devices of the people using it.
This release runs single-node: one device, no peers. The app interface is the one replicated mode will keep, so switching later is a config change, not a rewrite.
Use
import {configure} from 'carbondb'
import * as chat from './services/chat.js'
const carbon = await configure('./carbondb.toml')
carbon.services.add('chat', chat)
await carbon.call('POST [@carbon]/identity', {slug: 'alice', device: 'laptop'})
const personal = carbon.ref('identity:alice').ref('personal:alice')
await personal.call('POST [@carbon]/space', {name: 'Lobby', services: ['chat']})
const room = personal.ref('space:lobby')
await room.call('POST [chat]/messages', {text: 'hello'})
const stop = room.watch('GET [chat]/messages', (messages) => render(messages))# carbondb.toml
system = "data/system.db" # space files sit beside it
[sync]
mode = "none" # single-nodeThe interface
configure(path)loads CarbonDB from its TOML file and returnscarbon, the system handle.carbon.services.add(name, impl)registers a service on every start.implis the service's exported module:manifest, where its package'sservice.tomlis (new URL('./service.toml', import.meta.url));calls, keyed by service key, each with ahandler; and an optionalinit(db), which sets up the service's schema on every open, inside a transaction, and finishes before it returns. CarbonDB reads theservice.toml, and refuses with a 400 a service that exports none, or whoseservice.tomlit cannot read or holds a field it does not know. The service runs undername, which overrides the name in itsservice.toml. Its version is the hash of every file in its package's directory, leaving outnode_modules. A space naming a service this device has not registered opens and stays initializing: a GET to that service rejects with a 404, and every op on the space is logged and waits until the service is registered. An op made for a service this device has not registered is refused with a 404 when its turn comes. A name already registered, CarbonDB's own included, is refused with a 409, and a name holding a slash with a 400.- Schema: a service names its tables plainly; CarbonDB keeps them as
service/db/namein the space's one file, and refuses anything outside them. A row's key must come from the op that creates it, never from SQLite: afterinit, a table whose key is SQLite's rowid (INTEGER PRIMARY KEY, with or withoutAUTOINCREMENT) or that has no primary key is refused, with an error saying what to change. Useop.id(), orid INTEGER PRIMARY KEY DEFAULT (op_id())in aWITHOUT ROWIDtable for an integer key.carbondb/migrationsapplies a directory of D1-style migrations frominit:init: (db) => applyMigrations(db, readMigrations(url)). - Handles reach spaces, one per space:
carbon.ref('identity:alice').ref('personal:alice').ref('space:lobby'). A handle acts as the identity its ref chain passed through.ref()answers at once, read from the space's refs as committed, and throws a 404 for a ref that is not there. call(key, input)resolves with the handler's result: a GET at once, and a write once this device has applied it and every watch its commit touched, or that was running when it landed, has run again. It rejects with a service error from@tlsft/service-error: status, code, message and details intact.- Timeout: every call to a service must settle within the
timeoutitsservice.tomlnames, in milliseconds, or 200. One deadline covers a call and the calls it makes. When it passes, the call rejects with a504 TIMED_OUT, a write rolls back as though its handler threw, and whatever the handler answers later is dropped. A watch whose GET times out gets the error, and runs again after the next commit that writes what it read. watch(key, fn)runs a GET, then again after each commit that wrote a table it read.fngets each result, orundefinedand the error when the GET fails. It returns a function that stops the watch.- Run state:
runState()on a space handle says where the space is in applying its ops:{state: 'initializing', services}while a service the space names is not set up here,{state: 'idle'},{state: 'applying', op}, or{state: 'stopped', error}.watchRunState(fn)callsfnwith each change and returns a function that stops it. A space stops on a system failure, such as a full disk, an I/O error or another process holding its file: every call and watch on it then gets a503 SPACE_STOPPEDcarrying the failure, and nothing is logged or applied, until the problem is fixed by hand andresume()is called, for example from a button. The op that was applying applies again then. - Handlers receive
{path, input, env, call}, andopwhen the call writes.envholds the service'ssqlbinding under the name itsservice.tomlgives it, as a Worker'senvdoes:({env: {db}}) => db.all(...)forbinding = "db". A write runs in one transaction, committed when the handler resolves and rolled back when it throws.call(key, input)makes a call inside it: it sees the handler's writes so far, and when it fails only its own writes roll back and the error rejects thecall. A GET reads committed state, never waits for a write, and cannot call one.opcarries the author, device and time, withid()andrandom()derived from the op, so take ids, time and randomness from it, never fromDate.now()orMath.random(). Validating input is the service's own business: throwvalidationError. - Builtin calls: on
carbon,POST [@carbon]/identitycreates an identity, its first device and its personal space, andGET [@carbon]/identitieslists them; in a space,POST [@carbon]/spacecreates a space nested in it and answers its ref,GET [@carbon]/statusdescribes it, andGET [space]/refsandGET [device]/refslist its refs and devices. A handle has no methods beyond these calls: reach what a call created throughref. Calls that need peers, such asPOST [@carbon]/members, reject with501 SINGLE_NODE.
CarbonDB binds no protocol. To serve a space over HTTP, write an adapter that turns a request into space.call(key, input) and a service error into a response.
Building
The core is Rust, loaded through napi-rs. npm run build compiles it with cargo and copies the module beside src/.
