Skip to content

Repository files navigation

OlayJS

Event-shaped backend runtime — HTTP endpoints, durable domain side effects, and deferred work share one pipeline.

Turkish olay = event. Package: olay-js.

npm CI Node.js License: MIT TypeScript


Who it’s for

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.


Book

Why OlayJS exists, how the pipeline works, production metrics, and limits — as a short PDF.


Contents


Install

npm install olay-js zod express multer
# Optional — durable store
npm install mongoose

Peers: zod ^4, express ^5, multer ^1.4.5-lts.1|^2 (required). mongoose ^8|^9 optional for olay-js/mongo.

Node: >=24


Quickstart

Handlers return { status, message, data }. By default 1000 → HTTP 200 (status mapping).

HTTP event

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" } }

Local event (background)

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 });

How it works

Events

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

Fields

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.

Subscribe & handlers

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 → emit local events
  • Local: parallel groups, isolated context copies; a throw fails the whole execution

Blocking vs background vs deferred

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: pendingprocessingsuccess or failed.

Background ≠ worker: blocking: false still executes on the emitting process immediately. The worker loop only claims due { at } work and retryable failures.

The worker

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).

Idempotency (at-least-once)

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.

HTTP status mapping

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 vs MongoStore

MemoryStore MongoStore
Survives restart No Yes
Success history Deleted Kept
Use Tests, local play Durable deferred work & retries

Errors & onError

createOlay({
  store,
  onError: ({ error, phase, event }) => {
    // phase: "emit.blocking" | "emit.async" | "worker"
    console.error(phase, event?.name, error);
  },
});

Stable codes: OlayError / OlayErrorCodes (table).


More examples

HTTP → local side effect

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);
  },
]);

Deferred work

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.

Mongo store

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();

Project shape

src/
  fields/          # defineField once, reuse
  events/          # defineHTTPEvent / defineLocalEvent
  handlers/        # subscribe groups
  index.ts         # createOlay, subscribe, start

Common pitfalls

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

API reference

Concepts live in How it works.

createOlay(options)Olay

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

defineField / events

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.

subscribe / emit

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 }.

Lifecycle & helpers

app.subscribe(/* … */);
app.getApp().use(/* middleware — routes mount on start */);
await app.start();
await app.shutdown(); // does not disconnect mongoose

getApp(), getListeningPort(), getNodeId().

Inspect & cancel

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.

Stores

import { MemoryStore } from "olay-js";
import { MongoStore } from "olay-js/mongo";

new MemoryStore();
new MongoStore({ connection: mongoose.connection });

Handler result

type HandlerResult = {
  status: number;
  message: string | null;
  data: Record<string, unknown> | null;
};

Errors

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.


License

MIT — see LICENSE.


Used by

Looply      Modus

About

Event-shaped backend runtime: HTTP, local events, and schedules in one pipeline

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages