@digelo/signal
v1.0.0
Published
Production-grade backend framework for serverless environments. Meteor-like DX, stateless & database-agnostic.
Maintainers
Readme
Signal - Production-Grade Backend Framework
A Meteor-like backend framework redesigned for stateless, serverless, database-agnostic environments.
🎯 Philosophy
Signal brings the best of Meteor (named queries/mutations, automatic data management, events) to modern serverless architectures. No long-lived connections, no implicit subscriptions, no magic—just explicit, deterministic backend code.
🏗️ Architecture

📦 Key Features
✅ Non-Negotiable Design Constraints
- Framework-level, backend-only - Not a full-stack framework
- Named queries only - No implicit queries, all explicit
- Named mutations only - Mutations are the exclusive write path
- Stateless events - At-least-once, unordered, deterministic
- No server-side observers - No change streams, no DB triggers
- Deterministic handlers - Same input = same output
- Serverless-ready - Runs on Vercel, Fly, VPS, Edge
🏗️ Production Guarantees
- Runtime Immutability - Configuration frozen after startup
- Explicit Lifecycle -
configure()→register()→start() - Registry Integrity - Unique names, no overrides
- Context Safety - Immutable, request-scoped, serializable
- Access Control - Declarative, enforced before execution
- Error Model - Deterministic codes, safe serialization
- Input Validation - Lightweight, fail-fast
- Event Discipline - Stateless, stable naming, at-least-once
🚀 Quick Start
import { Signal, MemoryAdapter, InMemoryTransport } from "signal";
// 1. Create and configure
const signal = new Signal();
signal.configure({
db: new MemoryAdapter(),
transport: new InMemoryTransport(),
});
// 2. Register collection with access control
signal
.collection("posts")
.access({
query: { public: "public" },
mutation: { create: "auth" },
})
.query("public", async (params, ctx) => {
return await ctx.db.find("posts", { published: true });
})
.mutation("create", async (params, ctx) => {
const postId = await ctx.db.insert("posts", {
title: params.title,
authorId: ctx.auth.user?.id,
});
await ctx.emit("posts.created", { postId });
return { postId };
});
// 3. Start
await signal.start();
// 4. Execute queries/mutations
const posts = await signal.query("posts.public", {}, context);
const result = await signal.mutation("posts.create", {
title: "Hello",
}, context);📚 Architecture
Packages
/packages
/core # Signal.ts, Registry, Collection, Lifecycle, Context
/db # Database abstraction + adapters
/transport # Event bus + transport adapters
/http # HTTP handler, router, validation
/security # Auth, access control
/utils # Utilities (freeze, hash, logger)Core Concepts
Signal Instance
The main entry point, manages lifecycle and registry.
const signal = new Signal();
signal.configure({ db, transport, logger });
signal.collection("posts").query(...).mutation(...);
await signal.start();Collections
Groups queries and mutations under a single namespace with shared access control.
signal.collection("posts").access({
query: { public: "public", mine: (ctx) => ctx.auth.user != null },
mutation: { create: "auth" },
});Queries
Read-only operations with optional access control.
.query("public", async (params, ctx) => {
// params: { limit?: number, offset?: number, ... }
// ctx: immutable context with db, auth, emit
return await ctx.db.find("posts", { published: true });
})Mutations
Write operations, the exclusive write path.
.mutation("create", async (params, ctx) => {
const id = await ctx.db.insert("posts", { ...params });
await ctx.emit("posts.created", { id, ...params });
return { id };
})Context
Request-scoped, immutable, serializable.
interface SignalContext {
db: SignalDB; // Database adapter
auth: SignalAuth; // Authentication/authorization
emit: (name, payload) => Promise<void>;
request?: HTTPRequest; // Original request
env?: Record<string, any>;
}Access Control
Declarative rules enforced before execution.
access: {
query: {
public: "public", // Anyone
mine: (ctx) => ctx.auth.user != null, // Custom function
},
mutation: {
create: "auth", // Authenticated users
delete: "admin", // Admin users (custom rule)
},
}Events
Emitted only from mutations, at-least-once semantics.
await ctx.emit("posts.created", {
id: postId,
title: params.title,
authorId: ctx.auth.user?.id,
});🔒 Security
Authentication
Extract auth from headers or request:
import { AuthProvider } from "signal";
const auth = AuthProvider.fromHeaders(req.headers);
// or
const auth = AuthProvider.authenticated("user123", ["editor"]);Access Control
Declarative rules at collection level:
.access({
query: { public: "public", private: (ctx) => ctx.auth.user != null },
mutation: { create: "auth", delete: (ctx) => ctx.auth.user?.roles?.includes("admin") },
})Built-in rules: "public", "auth", "admin"
Custom rules: Functions (ctx) => boolean | Promise<boolean>
💾 Database Abstraction
Adapters
MemoryAdapter (development/testing)
new MemoryAdapter() // In-memory, no persistenceSqlAdapterBase (abstract base)
// Implement for PostgreSQL, MySQL, etc.
class PostgresAdapter extends SqlAdapterBase { ... }Methods
// Queries
await db.find("posts", { published: true });
await db.findOne("posts", { _id: "123" });
await db.findById("posts", "123");
await db.count("posts", { published: true });
// Writes (from mutations only)
const id = await db.insert("posts", { title: "Hello" });
await db.update("posts", id, { title: "Updated" });
await db.delete("posts", id);📡 Events & Transport
In-Memory Transport
const transport = new InMemoryTransport();
signal.configure({ transport });
// Subscribe
const unsub = await transport.getEventBus().subscribe("posts.*", async (event) => {
console.log("Event:", event.name, event.payload);
});Custom Transport
Implement SignalTransport:
export interface SignalTransport {
emit(event: SignalEvent): Promise<void>;
subscribe(pattern: string, handler: EventSubscriber): Promise<() => void>;
}🌐 HTTP Interface
Handler
import { createHandler } from "signal";
const handler = createHandler(signal);
app.post("/signal/query", handler);
app.post("/signal/mutation", handler);Endpoints
POST /signal/query
{
"key": "posts.public",
"params": { "limit": 10 }
}Response:
{
"ok": true,
"data": [...]
}POST /signal/mutation
{
"key": "posts.create",
"params": { "title": "Hello" }
}Response:
{
"ok": true,
"data": { "id": "..." }
}GET /signal/introspect
{
"ok": true,
"data": {
"collections": ["posts", "comments"],
"queries": ["posts.public", "posts.mine", "comments.thread"],
"mutations": ["posts.create", "posts.delete", "comments.reply"]
}
}🧪 Production Test Scenario
npm run testDemonstrates:
- Framework boot and configuration
- Collection registration with access control
- Query execution (public and authenticated)
- Mutation execution with event emission
- Access control enforcement
- Event subscription and delivery
- Registry immutability
📋 Lifecycle Phases
- CONFIGURING - Initial state, configure with
configure() - REGISTERING - Register collections, queries, mutations
- RUNNING - Operational, registry immutable
- FAILED - Unrecoverable error
const signal = new Signal(); // CONFIGURING
signal.configure({...}); // REGISTERING
signal.collection("posts").query(...).mutation(...);
await signal.start(); // RUNNINGAfter start(), no new collections/queries/mutations can be registered.
🛡️ Error Handling
Error Types
SignalError // Base error
SignalAuthError // Authentication required
SignalForbiddenError // Access denied
SignalValidationError // Input validation failed
SignalNotFoundError // Resource not found
SignalConflictError // Conflict (duplicate, etc.)
SignalInternalError // Internal error
SignalLifecycleError // Lifecycle violation
SignalRegistryError // Registry errorSafe Responses
No stack traces in production. Deterministic error codes:
{
"ok": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Input validation failed"
}
}📦 Type Safety
Full TypeScript support with strict mode:
interface QueryDef<Params, Result> {
name: string;
collectionName: string;
handler: QueryHandler<Params, Result>;
}🎨 Design Patterns
Immutability
// Configuration is frozen after creation
const config = new Config({ db, transport });
// Cannot mutate configExplicit Phases
// Clear lifecycle prevents accidental misuse
const signal = new Signal();
signal.configure(...); // Phase 1
signal.collection(...); // Phase 2
await signal.start(); // Phase 3 - lockedNamed Operations
// No implicit queries/mutations
await signal.query("posts.public", params, ctx);
await signal.mutation("posts.create", params, ctx);
// Not: db.posts.insert()Deterministic Handlers
// Same input, same output, every time
const query = async (params, ctx) => {
return await ctx.db.find("posts", params.filter);
};
// No side effects during query🚀 Deployment
Vercel
export default createHandler(signal);Fly.io
const handler = createHandler(signal);
Deno.serve({ port: 3000 }, handler);Express
const handler = createHandler(signal);
app.post("/signal/query", handler);
app.post("/signal/mutation", handler);📖 API Reference
Signal
configure(config)- Configure frameworkcollection(name)- Create collectionstart()- Start frameworkquery(key, params, ctx)- Execute querymutation(key, params, ctx)- Execute mutationgetRegistry()- Get registry for introspectiongetConfig()- Get configurationgetLogger()- Get loggergetPhase()- Get lifecycle phase
Collection
access(rules)- Set access controlquery(name, handler)- Register querymutation(name, handler)- Register mutation
AuthProvider
fromHeaders(headers)- Extract auth from headersauthenticated(userId, roles)- Create authenticated authanonymous()- Create anonymous authisAuthenticated(auth)- Check if authenticatedhasRole(auth, role)- Check role
Database
find(collection, query)- Find documentsfindOne(collection, query)- Find single documentfindById(collection, id)- Find by IDinsert(collection, doc)- Insert documentupdate(collection, id, update)- Update documentdelete(collection, id)- Delete documentcount(collection, query)- Count documents
📝 License
MIT
🤝 Contributing
This framework is production-ready and fully featured. Contributions should maintain the design constraints and production guarantees.
