Schema Facade

Type-safe facade built on top of the Erebus pubsub client

Schema Facade

The Schema Facade is a helper class that adds strong typing and runtime validation to the Erebus PubSub client.
It ensures that every message you publish or subscribe to matches the shape defined in your schema.


How does it work?

The facade wraps your PubSub client and binds it to a SchemaMap.
Each key in the schema represents a channel type (e.g. news, chat, presence), and defines the payload shape for that channel.

At runtime, you can create many dynamic topics (e.g. sports, finance) under the same schema key.
All topics under the same schema key share the same payload contract.


Example

import { ErebusPubSubSchemas, ErebusClient, ErebusClientState } from "@erebus-sh/sdk";
import { z } from "zod";

// 1. Define your schema
const schemas = {
  news: z.object({
    title: z.string(),
    body: z.string(),
    timestamp: z.number(),
  }),
} as const;


// 2. Create the client without the schema
const clientUnTyped = ErebusClient.createClient({
    client: ErebusClientState.PubSub,
    authBaseUrl: "http://localhost:4919", // your auth domain
    wsBaseUrl: "ws://localhost:8787", // your ws domain (optional if you self-host locally)
});

// 3. Create the client with the schema
const client = new ErebusPubSubSchemas(
    clientUnTyped,
    schemas,
);


// 4. Use it with type safety
client.publish("news", "sports", {
  title: "Team wins the finals",
  body: "Fans celebrate in the streets",
  timestamp: Date.now(),
});

await client.subscribe("news", "finance", (article) => {
  console.log(`[Finance] ${article.payload.title}`);
});

Key points

Good to know

The Schema Facade automatically combines the schema key and the topic ID into a full channel name.
For example, using ("news", "sports") becomes the topic string news_sports behind the scenes.

  • Schema key/Topic Schema → defines the payload shape (news, chat, etc.)
  • Topic → dynamic runtime identifier (sports, finance, room:123)
  • Compile-time safety → TypeScript enforces correct payloads per schema key
  • Runtime validation → Zod ensures payloads are valid before sending/after receiving

Why use it?

Without the facade, PubSub messages are just raw JSON strings. With the facade, you get:

  • Safer publishes (payload checked at compile time + runtime)
  • Safer subscriptions (payload automatically validated and typed)
  • Cleaner, self-documenting API
  • Typed history API (payloads validated when fetching history)

Typed History API

The Schema Facade also provides typed history methods:

getHistory()

Fetch historical messages with automatic type validation:

// Define schema
const schemas = {
  news: z.object({
    title: z.string(),
    body: z.string(),
    timestamp: z.number(),
  }),
};

const client = new ErebusPubSubSchemas(baseClient, schemas);

// Fetch history with typing
const history = await client.getHistory("news", "sports", {
  limit: 50,
  direction: "backward"
});

// TypeScript knows the payload type!
history.items.forEach(msg => {
  console.log(msg.payload.title);     // ✅ string
  console.log(msg.payload.body);      // ✅ string
  console.log(msg.payload.timestamp); // ✅ number
});

createHistoryIterator()

Create a typed paginator:

const getNext = client.createHistoryIterator("news", "finance", {
  limit: 20,
  direction: "backward"
});

const batch = await getNext();
if (batch) {
  batch.items.forEach(msg => {
    // Fully typed payload
    console.log(`${msg.payload.title}: ${msg.payload.body}`);
  });
}

Full Example with History

import { ErebusPubSubSchemas, ErebusClient, ErebusClientState } from "@erebus-sh/sdk";
import { z } from "zod";

// 1. Define schemas
const schemas = {
  chat: z.object({
    text: z.string(),
    username: z.string(),
    timestamp: z.number(),
  }),
};

// 2. Create typed client
const baseClient = ErebusClient.createClient({
  client: ErebusClientState.PubSub,
  authBaseUrl: "http://localhost:4919",
});

const client = new ErebusPubSubSchemas(baseClient, schemas);

// 3. Join and connect
client.joinChannel("my-channel");
await client.connect();

// 4. Subscribe with typing
await client.subscribe("chat", "lobby", (msg) => {
  // TypeScript infers the payload type
  console.log(`${msg.payload.username}: ${msg.payload.text}`);
});

// 5. Publish with typing
client.publish("chat", "lobby", {
  text: "Hello!",
  username: "Alice",
  timestamp: Date.now(),
});

// 6. Fetch history with typing
const history = await client.getHistory("chat", "lobby", {
  limit: 50
});

console.log(`Loaded ${history.items.length} messages`);
history.items.forEach(msg => {
  // Payload is automatically validated and typed
  console.log(`[${msg.payload.username}] ${msg.payload.text}`);
});

Schema Validation

The facade validates payloads at two points:

1. On Publish (Before Sending)

// ✅ Valid - passes type check and runtime validation
client.publish("news", "sports", {
  title: "Team Wins",
  body: "Great game!",
  timestamp: Date.now()
});

// ❌ Compile error - missing required field
client.publish("news", "sports", {
  title: "Team Wins",
  // body is missing
  timestamp: Date.now()
});

// ❌ Runtime error - wrong type
client.publish("news", "sports", {
  title: 123,  // should be string
  body: "Great game!",
  timestamp: Date.now()
});

2. On Receive (Subscribe & History)

await client.subscribe("news", "sports", (msg) => {
  // If validation fails, message is rejected
  // Only valid messages reach this callback
  console.log(msg.payload.title); // Guaranteed to be string
});

const history = await client.getHistory("news", "sports");
// All items have validated payloads
history.items.forEach(msg => {
  console.log(msg.payload.title); // Guaranteed to be string
});

Advanced Patterns

Multiple Schemas

const schemas = {
  chat: z.object({
    text: z.string(),
    username: z.string(),
  }),
  presence: z.object({
    userId: z.string(),
    status: z.enum(["online", "offline", "away"]),
  }),
  notification: z.object({
    title: z.string(),
    message: z.string(),
    priority: z.enum(["low", "medium", "high"]),
  }),
};

const client = new ErebusPubSubSchemas(baseClient, schemas);

// Each schema has its own type
client.publish("chat", "room-1", { text: "Hi", username: "Alice" });
client.publish("presence", "room-1", { userId: "123", status: "online" });
client.publish("notification", "alerts", { 
  title: "New Message", 
  message: "Alice says hi", 
  priority: "high" 
});

Schema Evolution

// Version 1
const schemasV1 = {
  message: z.object({
    text: z.string(),
  }),
};

// Version 2 - add optional fields for backwards compatibility
const schemasV2 = {
  message: z.object({
    text: z.string(),
    username: z.string().optional(),  // New field
    timestamp: z.number().optional(), // New field
  }),
};

Full example

Full example code