NubleStation

Blaze gives every app a database without SQL, connection strings, or direct Postgres access. You declare tables in TypeScript, push them with the CLI, and read/write through a typed client backed by auto-generated REST endpoints. Isolation between apps is enforced by the database itself — see Multi-tenancy & Isolation.

What works today

FeatureStatus
CRUD (list / get / create / update / delete)✅ Shipped
Schema push via nuble db push✅ Shipped
limit / offset pagination on list()✅ Shipped
Server-side filters, sort, joins⏳ Roadmap
SSE subscriptions (real-time)⏳ Roadmap

Anything marked Roadmap returns an error if you try it today. Track progress on the roadmap.

Get up and running

1. Install the SDK

npm install @nublestation/blaze
# or
pnpm add @nublestation/blaze

2. Define your schema

// schema.ts
import { defineSchema, t } from "@nublestation/blaze";

export const schema = defineSchema({
  tasks: {
    title:  t.string().required(),
    status: t.enum(["pending", "in_progress", "done"]).default("pending"),
  },
});

3. Push it

nuble db push

This creates the table, applies tenant isolation, and writes TypeScript types to .nuble/types.ts. See nuble db push for flags.

4. Create a client and query

import { createBlazeClient } from "@nublestation/blaze";
import { schema } from "./schema";

const { db } = createBlazeClient({
  baseUrl: "http://api.clinic.local",  // Gateway base URL
  apiKey:  "nbl_<key_id>.<secret>",    // API key from the Console
  schema,                               // enables typed table names + row shapes
});

const task  = await db.tasks.create({ title: "Review X-ray" });
const tasks = await db.tasks.list();

That’s it — you have a working multi-tenant database.

Everyday usage

Every table in your schema gets the same five methods:

// List all rows for your app (optional pagination)
const tasks = await db.tasks.list();
const page2 = await db.tasks.list({ limit: 20, offset: 20 });

// Read one row by id — throws if not found
const task = await db.tasks.get(taskId);

// Create — returns the persisted row with server-generated id
const created = await db.tasks.create({ title: "Order supplies" });

// Update — partial; only supplied fields are written
const updated = await db.tasks.update(taskId, { status: "done" });

// Delete
await db.tasks.delete(taskId);

Filtering today is client-side. list() returns all of your app’s rows; filter and sort in JavaScript after fetching:

const pending = (await db.tasks.list()).filter((t) => t.status === "pending");

Under the hood each method maps 1:1 to a REST endpoint (GET/POST /v1/blaze/db/{table}, GET/PATCH/DELETE /v1/blaze/db/{table}/:id) routed through the Gateway — you can call them directly with Authorization: Bearer <api key> if you’re not using the SDK.

Schema DSL reference

Field types (t.*):

BuilderPostgres typeNotes
t.string()text
t.number()numericJS number
t.decimal()decimalstring at the boundary, avoids float loss
t.boolean()boolean
t.uuid()uuid
t.timestamp()timestamptzISO-8601 string; .default("now")now()
t.json()jsonboptionally typed: t.json<MyShape>()
t.enum([...])enumat least one value required
t.ref("table")uuid FK{ onDelete: "cascade" } optional

Modifiers (chainable on any field):

ModifierEffect
.required()NOT NULL
.unique()unique constraint
.index()single-column index
.default(value)column default

For composite indexes, wrap the table in t.model({...}).index("a", "b") or .unique("a", "b").

Every table automatically gets an id (uuid, primary key) and an app_id (uuid, tenant discriminator) — never declare those yourself. Built-in names like users and files are reserved and rejected at push time.

Roadmap: server-side queries

Not shipped — do not use yet

Everything in this section is the design target, not implemented. Sending these query parameters today returns an error. Filter client-side as shown above until they land.

The planned list() query surface is PostgREST-inspired:

?status=eq.pending             status equals 'pending'
?title=ilike.*urgent*          title contains 'urgent' (case-insensitive)
?order=created_at.desc         sort descending
?select=id,title,status        projection

JSONB filters, relations, computed columns, and named queries follow the same release track — see the roadmap.

Limits & gotchas

  • list() returns all rows for your app (subject to limit/offset). No server-side WHERE yet — large tables mean large payloads.
  • Table names are reserved org-wide. A resource name like tasks maps to one physical table shared across apps (rows separated per app). The first app to push tasks defines its columns; an incompatible second definition is rejected at push time.
  • Migrations are append-only. Each push generates a checksummed migration; editing an already-applied migration is refused.
  • Concurrent pushes are serialized per app via a Postgres advisory lock — a second push waits, it doesn’t corrupt.
  • Reserved names (users, files, notifications, audit_log) cannot be used as table names in your schema.
  • Per-request quotas: max 1000 rows, 100 KB body, 30 s execution.
  • update() drops unknown fields silently — it does not error on extra keys.

Go deeper


Environment variables

VariableDescription
DATABASE_URLPostgres connection string (via PgBouncer)
INTERNAL_HMAC_SECRETShared HMAC secret — must match the gateway