Event-shaped backend runtime — HTTP endpoints, durable domain side effects, and deferred work share one pipeline.
Turkish olay = event. Package:
olay-js.
Express + Mongo-class backends that want one way to do HTTP, durable side effects, and “run this later” — reusable Zod fields, shared handlers, and a store that can outlive the request.
Runnable sample: examples/minimal-api.
Why OlayJS exists, how the pipeline works, production metrics, and limits — as a short PDF.
- Repo:
olayjs-book - PDF:
olayjs-book.pdf
npm install olay-js zod express multer
# Optional — durable store
npm install mongoosePeers: zod ^4, express ^5, multer ^1.4.5-lts.1|^2 (required). mongoose ^8|^9 optional for olay-js/mongo.
Node: >=24
Handlers return { status, message, data }. By default 1000 → HTTP 200 (status mapping).
import { createOlay, defineField, defineHTTPEvent, MemoryStore } from "olay-js";
import { z } from "zod";
const Hello = defineHTTPEvent({
name: "hello",
method: "GET",
path: "/hello",
fields: {
name: defineField({
schema: z.string().default("world"),
httpFrom: "query.name",
}),
},
});
const app = createOlay({ store: new MemoryStore(), port: 3000 });
app.subscribe(Hello, [
async (event) => ({
status: 1000,
message: null,
data: { greeting: `Hello, ${event.payload.name}` },
}),
]);
await app.start();
// GET /hello?name=olay → { status: 1000, message: null, data: { greeting: "Hello, olay" } }import { createOlay, defineField, defineLocalEvent, MemoryStore } from "olay-js";
import { z } from "zod";
const SyncCompany = defineLocalEvent({
name: "syncCompany",
fields: {
companyId: defineField({ schema: z.string().min(1) }),
},
});
const app = createOlay({ store: new MemoryStore() });
app.subscribe(SyncCompany, [
async (event) => {
await syncExternal(event.payload.companyId);
},
]);
await app.start();
await app.emit(SyncCompany, { companyId: "acme" }, { blocking: false });The unit of work is an event: a name, a payload shape, and handler pipeline(s). Define events, subscribe handlers, hit them over HTTP or emit them. Payload and context types are inferred from your fields.
HTTP (defineHTTPEvent) |
Local (defineLocalEvent) |
|
|---|---|---|
| Purpose | API endpoint | Domain / side effects / reminders |
| Extra config | method + path |
— |
| How it runs | Request → extract fields → blocking emit → JSON | emit — blocking, background, or { at } |
| Subscribers | Exactly one group | Multiple groups (in parallel) |
| Worker retries | No | Yes |
Reusable payload atoms (defineField) — not one-off schemas per route.
const Email = defineField({
schema: z.string().trim().toLowerCase().email(),
httpFrom: "body.email",
});
const Password = defineField({
schema: z.string().min(8),
httpFrom: "body.password",
redacted: true, // storage/logs only; handlers still see the real value
});
const ProjectId = defineField({
schema: z.string().min(1),
httpFrom: "params.projectId",
contextKey: "project",
fetch: async ({ value }) => loadProject(value),
});
defineHTTPEvent({
name: "invite",
method: "POST",
path: "/invite",
fields: {
email: Email,
contactEmail: Email.httpFrom("body.contactEmail"),
password: Password.optional(),
},
});httpFrom is required on every HTTP field. fetch hooks run in parallel (each sees only base context). Redacted fields cannot use background or { at } emit — secrets aren’t kept for a later worker run.
app.subscribe(Login, [
authenticate, // return { context: { user } } or a status result
async (_event, context) => ({
status: 1000,
message: null,
data: { token: context.user.token },
}),
]);
app.subscribe(ProjectCreated, [async (event) => notifySlack(event.payload.projectId)]);
app.subscribe(ProjectCreated, [async (event) => indexSearch(event.payload.projectId)]); // parallel- Nothing /
{ context }→ continue in the group;{ status, message, data }→ stop that group - HTTP: one group only; extra work →
emitlocal events - Local: parallel groups, isolated context copies; a throw fails the whole execution
Once accepted, every run does: validate → load field context → run handlers → record outcome.
| How you trigger it | What happens |
|---|---|
HTTP / { blocking: true } |
Handlers now; wait for the result |
{ blocking: false } |
Handlers now in the background; caller does not wait |
{ at: Date } |
Saved as pending until due; worker runs it later |
await app.emit(SyncCompany, { companyId: "acme" }, { blocking: true });
await app.emit(SyncCompany, { companyId: "acme" }, { blocking: false });
await app.emit(SendReminder, { companyId: "acme" }, { at: tomorrow });Stored local work: pending → processing → success or failed.
Background ≠ worker: blocking: false still executes on the emitting process immediately. The worker loop only claims due { at } work and retryable failures.
app.start() also starts a background loop that picks up local events waiting in the store:
- Scheduled with
{ at }whose time has come - Failed earlier and due for another try
Each cycle: mark this process alive → return abandoned work from dead processes → take the next due event → run it → save success or failed. One event at a time.
Retries: failed locals up to 5 attempts, ≥ 60s apart (including failed blocking: true locals). HTTP is request-path only.
With Mongo, several Node processes can share the queue. Pass a stable nodeId per process if you care about identifying owners; otherwise one is generated.
Inspect or drop waiting work with getById / find / cancel / cancelMatching (see API).
Delivery is at-least-once, not exactly-once. If handlers finish (email sent) and the process dies before the store records “done”, the worker may run them again.
app.subscribe(SendReminder, [
async (event) => {
if (await alreadySent(event.payload.companyId, event.id)) return;
await sendEmail(event.payload.companyId);
await markSent(event.payload.companyId, event.id);
},
]);Unique constraints, “already done” checks, and idempotency keys are the app’s contract.
const app = createOlay({
store,
statusMap: {
// default already includes 1000 → 200
1001: 401,
1002: 404,
},
});- Response JSON keeps
{ status, message, data } - Unmapped domain status →
UNMAPPED_HANDLER_STATUS(HTTP 500) - Invalid payload (Zod) → HTTP 400 (
INVALID_PAYLOAD) - No handler result → synthetic
{ status: 1000 }/ HTTP 200
| MemoryStore | MongoStore | |
|---|---|---|
| Survives restart | No | Yes |
| Success history | Deleted | Kept |
| Use | Tests, local play | Durable deferred work & retries |
createOlay({
store,
onError: ({ error, phase, event }) => {
// phase: "emit.blocking" | "emit.async" | "worker"
console.error(phase, event?.name, error);
},
});Stable codes: OlayError / OlayErrorCodes (table).
One HTTP group; side effects are local emits (complete definitions):
const Email = defineField({
schema: z.string().email(),
httpFrom: "body.email",
});
const Password = defineField({
schema: z.string().min(8),
httpFrom: "body.password",
redacted: true,
});
const CompanyId = defineField({ schema: z.string().min(1) });
const Login = defineHTTPEvent({
name: "login",
method: "POST",
path: "/auth/login",
fields: { email: Email, password: Password },
});
const SyncCompany = defineLocalEvent({
name: "syncCompany",
fields: { companyId: CompanyId },
});
app.subscribe(Login, [
authenticate, // → { context: { user } }
async (_event, context) => {
await app.emit(SyncCompany, { companyId: context.user.companyId }, { blocking: false });
return { status: 1000, message: null, data: { token: context.user.token } };
},
]);
app.subscribe(SyncCompany, [
async (event) => {
await syncExternal(event.payload.companyId);
},
]);const SendReminder = defineLocalEvent({
name: "sendReminder",
fields: {
companyId: defineField({ schema: z.string().min(1) }),
},
});
app.subscribe(SendReminder, [
async (event) => {
await sendEmail(event.payload.companyId);
},
]);
await app.emit(SendReminder, { companyId: "acme" }, {
at: new Date(Date.now() + 60_000),
});
await app.start();Prefer MongoStore if the reminder must survive a restart.
App owns mongoose — shutdown() stops worker + HTTP but does not disconnect the DB.
import mongoose from "mongoose";
import { createOlay } from "olay-js";
import { MongoStore } from "olay-js/mongo";
await mongoose.connect(process.env.MONGO_URL!);
const app = createOlay({
store: new MongoStore({ connection: mongoose.connection }),
port: 3000,
});
// … subscribe …
await app.start();
await app.shutdown();
await mongoose.disconnect();src/
fields/ # defineField once, reuse
events/ # defineHTTPEvent / defineLocalEvent
handlers/ # subscribe groups
index.ts # createOlay, subscribe, start
| Pitfall | What happens |
|---|---|
HTTP event with no subscribe |
Fails at start() (HTTP_MISSING_SUBSCRIBER) |
Second HTTP subscribe |
Throws immediately (HTTP_MULTIPLE_SUBSCRIBERS) |
Return status 1001 without statusMap entry |
HTTP 500 (UNMAPPED_HANDLER_STATUS) |
redacted: true + { blocking: false } or { at } |
Rejected (REDACTED_REQUIRES_BLOCKING) |
| Expect MemoryStore to keep work after restart | Queue is gone — use Mongo |
shutdown() then wonder why Mongo is still up |
App must call mongoose.disconnect() |
| Non-idempotent email/sync handlers | Duplicates under crash + reclaim |
Concepts live in How it works.
| Option | Type | Default | Notes |
|---|---|---|---|
store |
Store |
required | MemoryStore or MongoStore |
port |
number |
3000 |
0 = ephemeral |
statusMap |
Record<number, number> |
{ 1000: 200 } |
Merged over defaults |
nodeId |
string |
generated | Per-process id when sharing a store |
http.jsonLimit |
string | number |
"2mb" |
|
onError |
(info) => void |
— | emit.blocking | emit.async | worker |
| API | Notes |
|---|---|
defineField({ schema, httpFrom?, redacted?, contextKey?, fetch? }) |
.optional(), .httpFrom(source) |
defineHTTPEvent({ name, method, path, fields }) |
Every field needs httpFrom |
defineLocalEvent({ name, fields }) |
One shared const per name |
httpFrom paths: body, query, params, headers, file, raw, ip, plus dotted forms (body.email, file.avatar, …), or (request) => unknown.
| HTTP | Local | |
|---|---|---|
| Groups | Exactly one | Multiple, parallel |
Early { status, message, data } |
Stops that group | Stops that group; siblings still run |
emit options |
Behavior |
|---|---|
{ blocking: true } |
Wait for handlers + result |
{ blocking: false } |
Background on this process; retryable on failure |
{ at: Date } |
Pending until due (worker) |
blocking + at |
Error |
Redacted + non-blocking / at |
Error |
No separate schedule() — use { at }.
app.subscribe(/* … */);
app.getApp().use(/* middleware — routes mount on start */);
await app.start();
await app.shutdown(); // does not disconnect mongoosegetApp(), getListeningPort(), getNodeId().
await app.getById({ id });
await app.find({ name, status, payload, limit });
await app.cancel({ id }); // pending | failed → deleted
await app.cancelMatching({ name, payload });Does not cancel in-flight processing.
import { MemoryStore } from "olay-js";
import { MongoStore } from "olay-js/mongo";
new MemoryStore();
new MongoStore({ connection: mongoose.connection });type HandlerResult = {
status: number;
message: string | null;
data: Record<string, unknown> | null;
};| Code | Meaning |
|---|---|
INVALID_PAYLOAD |
Zod accept failed |
REDACTED_REQUIRES_BLOCKING |
Secrets on async / deferred emit |
BLOCKING_WITH_SCHEDULE |
blocking + at |
HTTP_MULTIPLE_SUBSCRIBERS |
Second HTTP group |
HTTP_MISSING_SUBSCRIBER |
No HTTP group at start |
UNMAPPED_HANDLER_STATUS |
Domain status missing from statusMap |
DUPLICATE_EVENT_NAME |
Conflicting definition for a name |
UNKNOWN_EVENT |
Unregistered name at execute |
ALREADY_INITIALIZED |
start() twice |
Client-ish adapter errors → HTTP 400; otherwise → 500.
MIT — see LICENSE.



