Files
zeekay 5f5fccc682 Open the realtime stream with a grant, and listen for named events
Base serves the stream only to a grant: an authenticated POST to
<prefix>/realtime/token mints one, good once for thirty seconds, and the
EventSource opens <prefix>/realtime?token=<grant>. The client opened the
stream with no grant, so on an org's Base every stream was anonymous and
carried nothing a rule kept from strangers. It now mints a grant on every
connect and reconnect, and opens anonymously only with no token.

Base names each event after the subscription it answers ("people/*"), and
the client listened only to unnamed events, so no record event ever
reached a subscriber. Each subscription now listens for its own name.
collection().unsubscribe() dropped nothing, because it removed a key the
map never held; it now drops that collection's topics and resubmits.

The password, OAuth, verification and reset calls are gone, with
useAuth's signIn and signUp: Base has none of those routes, and sign-in is
Hanzo IAM's.

Declarations are emitted by tsc. TypeScript 7 has no JavaScript API for
tsup's dts bundler, so the build had failed since the move to it.
2026-10-18 20:49:41 -07:00

4.5 KiB

@hanzo/base

npm

The JavaScript/TypeScript client for Hanzo Base, the reactive backend. Auth is Hanzo IAM-native: the client holds the IAM-issued JWT and validates it locally.

Zero runtime dependencies. Records/collections CRUD, realtime (SSE), files, reactive queries with optimistic writes, and CRDT primitives — with optional React bindings.

There is a companion Dart client, hanzo, that mirrors this shape.

Install

npm install @hanzo/base
import { BaseClient } from '@hanzo/base'

const base = new BaseClient('https://base.hanzo.ai')

Auth (Hanzo IAM)

Sign-in is Hanzo IAM's (PKCE in a browser, a key or client credentials on a server). Base has no password, OAuth, verification or reset routes of its own, so the client is handed the IAM-issued token and carries it on every request:

base.authStore.save(iamAccessToken, null)

base.authStore.isValid   // JWT present and unexpired
base.authStore.token     // the IAM JWT

base.signOut()

Persist the session in the browser:

import { BaseClient } from '@hanzo/base'
import { LocalAuthStore } from '@hanzo/base/compat'

const base = new BaseClient({
  url: 'https://base.hanzo.ai',
  authStore: new LocalAuthStore(),
})

Records / collections

const page = await base.collection('posts').getList(1, 20, {
  filter: 'published = true',
  sort: '-created',
})

const one = await base.collection('posts').getOne('RECORD_ID')
const made = await base.collection('posts').create({ title: 'hi' })
await base.collection('posts').update('RECORD_ID', { title: 'yo' })
await base.collection('posts').delete('RECORD_ID')

Realtime (SSE)

const off = base.collection('posts').subscribe('*', (e) => {
  console.log(e.action, e.record) // 'create' | 'update' | 'delete'
})
off()

An EventSource cannot send a header and the token never travels in a query string, so the stream is opened with a grant: the client POSTs <prefix>/realtime/token with the token, opens <prefix>/realtime?token=<grant> with what comes back, and mints a new grant on every reconnect. Collection rules decide which events reach the stream. On Hanzo Cloud an org's Base is at /v1/base:

const base = new BaseClient({ url: 'https://api.hanzo.ai', prefix: '/v1/base' })

Files

const url = base.files.getURL(record, 'photo.png', { thumb: '100x100' })

Reactive queries + CRDT

@hanzo/base ships a reactive QueryStore with optimistic updates and server reconciliation, plus CRDT primitives (@hanzo/base/crdt) for collaborative state. React bindings live at @hanzo/base/react.

import { QueryStore } from '@hanzo/base'
import { CRDTText } from '@hanzo/base/crdt'

Develop

npm install
npm run build
npm test

License

MIT © Hanzo AI, Inc. See LICENSE.

@hanzo/base/rest — the table wire

A sibling of the collection client, not a replacement. That one speaks Base's own /v1 surface: collections, records, realtime, CRDT. This one speaks the table wire at <prefix>/rest/{table}, where a table is the path and the filters are query params — the shape the console's data grid is built on.

import { base } from '@hanzo/base/rest'

const db = base('https://base.example', key)

const { data, count } = await db
  .from('posts')
  .select('id,title')
  .eq('status', 'live')
  .order('created', { ascending: false })
  .limit(20)
  .count()

Filters are eq neq gt gte lt lte like ilike is in, with .not(column, op, value) negating any of them. Writes are .insert(), .update() and .delete(), and return nothing unless .select() asks for the rows back.

Three things worth knowing, because each is where a client usually gets it wrong:

  • An unasked count is null, never 0. The server does not compute a total unless asked, so reporting zero would be inventing an answer to a question nobody put.
  • A query sends nothing until it is awaited, so a builder can be passed around and narrowed without anyone issuing a request by holding one.
  • update and delete require a filter. They act on every row the filter selects, and an unfiltered one would mean the whole table — the server refuses it rather than applying it.

Failures carry { message, details, hint, code }. code is what to branch on:

const { error } = await db.from('posts').insert({ title })
if (error?.code === '23505') return 'that title is taken'