npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ordinatio/scheduling

v0.5.1

Published

Scheduling engine for appointment-based businesses. API-first, no iframes.

Readme

@ordinatio/scheduling

A booking engine. It finds open times, books one, and refuses a second booking for the same time. It keeps its data in your own Postgres database.

Status: version 0.3.0, on npm. A runnable starter that installs it from a packed tarball is in examples/scheduling-starter in this repository.

Words used here

  • Calendar: one person or room that can be booked.
  • Appointment type: a kind of booking, such as "Consultation, 30 minutes".
  • Weekly hours: the hours a calendar is open each week.
  • Slot: one open time that can be booked.
  • Adapter: a small piece of code you supply for anything outside the database: payment, email, text messages, calendar sync, the clock, sign-in.

What you need

  • Node 22 or newer.
  • A Postgres database with nothing in it. Write down its address. It looks like postgresql://user:password@localhost:5432/your_empty_database.
  • npm. (pnpm works too.)
  • Peer dependency: @prisma/client 7.3 or newer. This README was tested with 7.6.0. The quick start also uses @prisma/adapter-pg to connect to Postgres.

Quick start

You will build a small project that books one appointment and then shows a second booking for the same time being refused. It takes about five minutes.

1. Make a project

Make an empty folder and open a terminal in it. Then run:

npm init -y
npm pkg set type=module
npm install @ordinatio/scheduling @prisma/[email protected] @prisma/[email protected]
npm install --save-dev [email protected] tsx

2. Build the tables

Put your own database address in the first line. The package brings its own table definitions and one migration. These two commands create the tables in your empty database.

export DATABASE_URL="postgresql://user:password@localhost:5432/your_empty_database"
npx prisma generate --config node_modules/@ordinatio/scheduling/prisma.config.ts
npx prisma migrate deploy --config node_modules/@ordinatio/scheduling/prisma.config.ts

3. Write the script

Save this as book.ts in the same folder. Every table belongs to an organization, so the script first adds one row for it. That row is the only thing your app must provide.

import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import {
  createCalendar,
  createAppointmentType,
  setWeeklyAvailability,
  getAvailableSlots,
  bookAppointment,
} from '@ordinatio/scheduling';

const connectionString = process.env.DATABASE_URL?.trim();
if (!connectionString) {
  console.error('DATABASE_URL is not set.');
  process.exit(2);
}

const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });

// The next weekday at least two days away, at noon UTC, so it is the same day in the Americas.
function nextWeekday(): Date {
  const day = new Date();
  day.setUTCHours(12, 0, 0, 0);
  day.setUTCDate(day.getUTCDate() + 2);
  while ([0, 6].includes(day.getUTCDay())) day.setUTCDate(day.getUTCDate() + 1);
  return day;
}

async function main(): Promise<void> {
  const timezone = 'America/New_York';
  const suffix = Date.now().toString(36);

  const organization = await prisma.organization.upsert({
    where: { id: 'demo-org' },
    update: {},
    create: { id: 'demo-org', name: 'Demo Studio' },
  });

  const calendar = await createCalendar(prisma, {
    organizationId: organization.id,
    name: 'Front desk',
    slug: `front-desk-${suffix}`,
    type: 'STAFF',
    timezone,
  });
  const appointmentType = await createAppointmentType(prisma, {
    organizationId: organization.id,
    name: 'Consultation',
    slug: `consultation-${suffix}`,
    duration: 30,
    calendarIds: [calendar.id],
  });
  await setWeeklyAvailability(
    prisma,
    calendar.id,
    [1, 2, 3, 4, 5].map((dayOfWeek) => ({ dayOfWeek, startTime: '09:00', endTime: '17:00', minNotice: 0 })),
  );

  const { slots } = await getAvailableSlots(prisma, {
    calendarId: calendar.id,
    date: nextWeekday(),
    duration: appointmentType.duration,
    timezone,
  });
  if (slots.length === 0) throw new Error('No open slots were found.');
  const slot = slots[0];
  console.log(`Open slots on the demo day: ${slots.length}. Booking the first, at ${slot.start.toISOString()}.`);

  const booking = await bookAppointment(prisma, {
    calendarId: calendar.id,
    typeId: appointmentType.id,
    startTime: slot.start,
    timezone,
    clientName: 'Jane Smith',
    clientEmail: '[email protected]',
  });
  console.log(`Booked: ${booking.id}`);
  console.log(`  ${booking.clientName} <${booking.clientEmail}>`);
  console.log(`  ${booking.typeName} with ${booking.calendarName}`);
  console.log(`  ${booking.startTime.toISOString()} to ${booking.endTime.toISOString()} (${booking.status})`);

  try {
    await bookAppointment(prisma, {
      calendarId: calendar.id,
      typeId: appointmentType.id,
      startTime: slot.start,
      timezone,
      clientName: 'John Doe',
      clientEmail: '[email protected]',
    });
  } catch (error) {
    const code = (error as { code?: string }).code;
    if (code !== 'SCHED_301') throw error;
    console.log(`Second booking for the same slot was refused: ${code} (slot not available).`);
    console.log('Demo complete.');
    return;
  }
  throw new Error('The second booking for the same slot was accepted. Double booking must be refused.');
}

main()
  .catch((error) => {
    console.error(error instanceof Error ? error.message : error);
    process.exitCode = 1;
  })
  .finally(() => prisma.$disconnect());

4. Run it

npx tsx book.ts

It prints this. The times and the booking id will differ.

Open slots on the demo day: <count>. Booking the first, at <time>.
Booked: <id>
  Jane Smith <[email protected]>
  Consultation with Front desk
  <start> to <end> (CONFIRMED)
Second booking for the same slot was refused: SCHED_301 (slot not available).
Demo complete.

The second booking is refused by the database, not only by the script. The package's migration creates a unique index on live bookings, so two people who book the same slot at the same moment cannot both win.

Check that this README still works

From this package's folder, run bash scripts/check-readme.sh. It follows the steps above in an empty folder against a scratch Postgres database and compares the output with the block above. It needs a local Postgres that your user can create databases in.

Use it in an app you already have

The tables are defined in node_modules/@ordinatio/scheduling/prisma/schema.prisma. Add those models to your own Prisma schema, or keep them in their own database. Bookings need an Organization row. If your app already has an Organization table, merge the two by hand and check the migration before you run it.

Adapters

The engine does not call any vendor. Everything outside the database goes through small adapters you supply, so the same engine runs in tests, in an example app, and in production.

Not every adapter is wired into the engine yet:

  • Wired today (through adaptersToCallbacks): notifications and payments.
  • Interface and in-memory version only, not yet used by the engine: clock, calendarSync, auth. The engine still reads the system time, and calendar sync and sign-in still live in the host app. A custom clock does not change the engine's idea of "now" yet.

| Adapter | Job | Promise the contract check enforces | |---|---|---| | clock | The current time | now() returns a valid Date and does not go backwards across six consecutive calls | | notifications | Send an email or text | Never reports SENT for an empty or blank recipient | | payments | Charge, verify, refund | A zero charge fails; an unknown payment is not paid; verify returns the metadata, and the charged amount when the adapter can convert the currency; a fully refunded payment is not paid; a partial refund needs an idempotencyKey and works up to what is left, never beyond; a repeated refund key refunds once; a repeat refund says ALREADY_REFUNDED; a repeated charge idempotencyKey charges once | | calendarSync | Add, move, remove a calendar event | Repeat upserts do not throw and keep one external id when ids are returned; removing a missing event does not throw | | auth | Turn request headers into { userId, organizationId, roles } | Returns null, never throws, for missing or unknown credentials |

The checks cannot see inside your calendar provider, so "one event per appointment" is yours to test beyond what the external id shows.

import { createInMemoryAdapters, adaptersToCallbacks, bookAppointment } from '@ordinatio/scheduling';

const { adapters, records } = createInMemoryAdapters();
await bookAppointment(db, input, adaptersToCallbacks(adapters));
console.log(records.sent); // the confirmation that would have been sent

createInMemoryAdapters() never touches the network. It records every message, charge, refund and calendar event, and gives a clock you can move (clock.set(date), clock.advance(ms)). The clock starts at a fixed date (2026-01-01 UTC), not the real time, unless you pass now.

Check your own adapter

Each adapter has a contract check that returns a list of plain-language failures. An empty list means the adapter keeps the promises above.

import { checkPaymentContract } from '@ordinatio/scheduling';

// A payment the client confirms in the browser (charge returns REQUIRES_ACTION) needs a way to confirm it
// here, for example the provider's test card. Without completePayment the check fails instead of passing.
const failures = await checkPaymentContract(myStripeAdapter, {
  completePayment: async (charge) => confirmWithTestCard(charge.paymentId!),
});
if (failures.length > 0) throw new Error(failures.join('\n'));

Available: checkClockContract, checkNotificationContract, checkPaymentContract, checkCalendarSyncContract, checkAuthContract. Run them against a sandbox account, not a live one: the payment check makes a real charge and refund (use the provider's test mode), the notification check sends a real message to [email protected], and the calendar check creates and removes a real event. Pass valid credentials as the second argument to checkAuthContract, or it cannot check that valid sign-ins work.

Webhooks

An organization can register web addresses that are told when something happens to a booking. The events are appointment.booked, appointment.rescheduled, appointment.cancelled, appointment.no_show, reminder.sent and waitlist.offered. Run prisma migrate deploy after updating: webhooks need the WebhookEndpoint, WebhookDelivery and WebhookAttempt tables.

import {
  createWebhookEndpoint, enqueueAppointmentWebhook, deliverDueWebhooks,
} from '@ordinatio/scheduling';

// 1. Add an address. The secret is returned once; store it where the receiver can read it.
const { endpoint, secret } = await createWebhookEndpoint(db, {
  organizationId, url: 'https://example.com/hooks/scheduling', eventTypes: ['appointment.booked', 'appointment.cancelled'],
});

// 2. When something happens, queue it. This never throws; the booking is already saved.
await enqueueAppointmentWebhook(db, { organizationId, type: 'appointment.booked', appointmentId });

// 3. On a timer, send what is due. Two callers at once never send the same delivery twice.
setInterval(() => {
  deliverDueWebhooks(db).catch((error) => console.error('Webhook delivery failed:', error));
}, 10_000);

A delivery that fails is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After the sixth failure it is DEAD_LETTER; retryWebhookDelivery puts it back. Any 2xx answer counts as delivered. A redirect counts as a failure and is not followed. Every try is kept in WebhookAttempt.

On the receiving side, check the signature on the raw body before trusting it:

import { verifyWebhookSignature } from '@ordinatio/scheduling';

const ok = verifyWebhookSignature({
  secret,                                                    // the whsec_... value from step 1
  header: String(request.headers['x-scheduling-signature'] ?? ''), // t=<seconds>,v1=<hex>
  body: rawBody,                                             // the exact text received, not re-serialized JSON
});

The header carries a timestamp and the check refuses anything more than 5 minutes old, so a captured delivery cannot be replayed later. The same event can arrive more than once (a retry after a timeout the receiver did answer), so act on id once. Each delivery also carries x-scheduling-event and x-scheduling-delivery.

Safety. Addresses must be https and public; localhost, private networks and cloud metadata addresses are refused when the endpoint is added, and again at the moment of connecting. Pass allowInsecureTargets: true to createWebhookEndpoint and deliverDueWebhooks only in local development. The secret is stored readable, because signing needs it; protect database backups as you would any key. To rotate a secret, add a new endpoint and remove the old one.

To also queue reminder.sent, call processPendingNotifications(db, sender, { webhooks: true }).

Waitlist

A client can wait for a slot or day that is full. When a booking is cancelled or moved, the slot it held is offered to the person who has waited longest and whose appointment type fits it. Run prisma migrate deploy after updating: the waitlist needs the WaitlistEntry table.

import { joinWaitlist, offerFreedSlot, expireWaitlistOffers, beginWaitlistAccept, completeWaitlistAccept, releaseWaitlistAccept } from '@ordinatio/scheduling';

// 1. A client joins a full day (or one full slot). Joining is refused while the day still has room: they should book.
const { token } = await joinWaitlist(db, { organizationId, typeId, day: '2026-11-03', clientName: 'Jane', clientEmail: '[email protected]' });

// 2. A booking was cancelled: offer its slot to the first person in line. Returns null when nobody fits.
const offer = await offerFreedSlot(db, { organizationId, calendarId, startTime: cancelledStart });
if (offer) await sendEmail(offer.entry.clientEmail, `https://example.com/offer?token=${offer.token}`);

// 3. On a timer: end offers whose time ran out, and offer each freed slot to the next person.
const { lapsed } = await expireWaitlistOffers(db);
for (const slot of lapsed) await offerFreedSlot(db, slot);

When the client opens their link, take the slot in three steps so the booking runs through your normal booking path: beginWaitlistAccept(db, token) claims the offer (two clicks at once: one wins), you book, then completeWaitlistAccept(db, entryId, appointmentId). If the booking did not happen, call releaseWaitlistAccept(db, entryId, outcome): 'KEEP_OFFER' for a temporary failure (the offer stays open), 'BACK_IN_LINE' when the slot was taken (the client keeps their place), or 'DROP' when a booking rule refuses them (they come off the list). 'DROP' returns the slot.

Pass freed slots on. Three calls return a FreedSlot (or null) that you must hand to offerFreedSlot, or the next person never hears about it: leaveWaitlist and removeWaitlistEntry (when the entry held an offer) and releaseWaitlistAccept(..., 'DROP'). expireWaitlistOffers returns its lapsed list for the same reason; it also settles an accept that started more than five minutes ago and never finished (booked if the booking was saved, otherwise back in line with its slot added to lapsed). If an accept looks like it failed, call settleBookedAccept(db, entryId) before releasing it: a booking can be saved and a later step fail, and it marks the entry booked and returns the booking when the client already holds the slot.

What an offer is. First dibs, not a hold: until the client accepts, the slot is still open to anyone who books it, and if someone does, accepting fails and the client keeps their place. One live offer holds a slot at a time (a database rule), and one person is never offered two slots at once. A waitlist is not available for a type that needs payment or has required intake questions (an accepted offer books with no answers); joinWaitlist refuses both. Joining checks no one owns the email address, so offer messages can reach a person who did not ask for them; put your own check (or a CAPTCHA) in front of it for a public page. token is the client's own handle (it opens the offer and leaves the list); send it only to them.

Routing forms

A routing form asks a client a few questions and sends them to the right appointment type or calendar ("What do you need? Suit, shirt, repair"). Run prisma migrate deploy after updating: routing forms need the RoutingForm table.

import { createRoutingForm, routeClient } from '@ordinatio/scheduling';

// An admin saves a form. Nothing is stored unless the whole form is valid.
await createRoutingForm(db, organizationId, {
  name: 'Fitting finder',
  slug: 'fitting-finder',
  questions: [
    { id: 'service', label: 'What do you need?', type: 'select', required: true, options: ['Suit', 'Shirt', 'Repair'] },
    { id: 'budget', label: 'Budget', type: 'number' },
  ],
  rules: [
    // Rules run in order; the first whose conditions hold wins.
    { id: 'big-suit', match: 'all',
      conditions: [{ questionId: 'service', operator: 'equals', value: 'Suit' }, { questionId: 'budget', operator: 'gte', value: 2000 }],
      destination: { typeId: premiumTypeId, calendarId: seniorCalendarId } },
    { id: 'suit', conditions: [{ questionId: 'service', operator: 'equals', value: 'Suit' }], destination: { typeId: suitTypeId } },
  ],
  // Required: what happens when no rule matches.
  fallback: { kind: 'MESSAGE', message: 'Please call the shop and we will find a time.' },
});

// A client's answers decide where they go.
const outcome = await routeClient(db, { organizationId, slug: 'fitting-finder', answers: { service: 'Suit', budget: 2500 } });
// { destination: { typeId, typeSlug, typeName, calendarId, calendarSlug, calendarName }, message: null, matchedRuleId: 'big-suit', usedFallback: false }

Question types are select, radio (one of the options), checkbox (a list of options), text and number. Operators depend on the type: choice questions allow equals, not_equals, in; checkbox allows contains, not_contains; text allows equals, not_equals, contains (ignoring case, whereas equals is exact), not_contains; number allows equals, not_equals, gt, gte, lt, lte; every type allows is_answered and is_empty. A rule's match is all (default) or any. A question the client left unanswered satisfies only is_empty, so "not equal to X" does not match someone who skipped it.

Checked when saved. createRoutingForm and updateRoutingForm refuse a form that has a rule naming a question that does not exist, an operator that does not fit the question, a value that is not one of its options, a form with no question, no rule or no fallback, or a destination that is not live in the organization (the type or calendar is missing, switched off, or the type is not offered on that calendar). They throw SCHED_1200 with every problem, each with its place such as rules[2].conditions[0].value, in error.context.errors. An update checks the whole merged form again, destinations included, when it changes the questions, rules or fallback; a change that leaves those alone (a rename, switching the form off) is always allowed, even if a destination has been switched off since. A slug is unique within the organization and also may not be used by another organization's form in the same database, so a public link by slug alone is never ambiguous (a host with a database per organization names the organization instead).

Checked when used. Destinations are looked up again at the moment a client is routed: a rule whose type or calendar was switched off since saving is skipped, and the next matching rule (or the fallback) is used. If the fallback destination is gone too, the client is told to get in touch. getPublicRoutingForm returns the questions only; the rules stay on the server. testRoutingForm tries sample answers, even on a form that is switched off, and lists every rule that matched.

Limits. 20 questions, 50 rules, 10 conditions per rule, 50 options per question, 50 forms per organization. Answers are not stored: routing a client leaves no record.

Organization brand

Each organization can have a brand: a name, logo, colours, font and footer for the booking page and its messages, and an identity its messages are sent as. Run prisma migrate deploy after updating: it needs the OrganizationBrand table.

import { saveBrand, getBrand, getPublicBrand } from '@ordinatio/scheduling';

await saveBrand(db, organizationId, {
  displayName: 'Acme Fittings',
  logoUrl: 'https://cdn.acme.example/logo.png',
  primaryColor: '#112233',
  footerText: 'Acme, 1 Main St',
  replyToEmail: '[email protected]',
  fromName: 'Acme Bookings',
  fromEmail: '[email protected]',
}, { isSendingDomainVerified: async (domain) => myMailProvider.isVerified(domain) });

saveBrand replaces the whole brand and refuses (SCHED_1400) one with any problem, listing every problem with its field. Colours must be hex (#1a2b3c), the logo an https address, font names plain words, and addresses single plain addresses. A sending address (fromEmail) is accepted only for a domain isSendingDomainVerified confirms. Without that function no organization can set one, so an organization can never send as a domain it does not control; replyToEmail needs no check. The package does not verify domains itself: confirming that your mail provider will send for the domain (SPF, DKIM) is the host's job.

On the booking page. getPublicBrand returns what a visitor may see (never an email address). The API package serves it at GET /brand and the booking widget applies it.

In messages. Pass { brand: true } to scheduleNotifications, queueImmediateNotification or queueStaffNotification and a template can use {{brandName}}, {{brandLogoUrl}}, {{brandColor}} and {{brandFooter}} (empty text when the organization has no brand; HTML-escaped in email bodies). Pass brand: true to processPendingNotifications and each message reaches your sender with context.from set to its organization's identity ({ name, email?, replyTo? }); a notification adapter receives the same as message.from. Your sender decides what to do with it: use email as the From address (it is on a domain you confirmed), name as the display name and replyTo as Reply-To, or ignore from and send from your own address.

Without those options nothing changes and the brand table is never read. The brand fields in a message are filled in when it is queued, so a message queued before the brand was saved or changed keeps the text it was rendered with. The sender identity is read when the message is sent, so it follows the brand as it is at that moment, and a domain the host stops confirming keeps being used until the brand is saved again. A sender name may not contain quotes, angle brackets, commas, semicolons, @ or backslashes.

Data export and erasure

Answers a person's request to see, or to be forgotten. Run prisma migrate deploy after updating: it needs the DataSubjectRequest table. A person is named by an email address (any letter case), a client id, or both; a client id also covers every address that client booked with in the organization.

import { exportSubject, previewErase, eraseSubject } from '@ordinatio/scheduling';

const file = await exportSubject(db, { organizationId, requestedBy: staff.userId, email: '[email protected]' });
const preview = await previewErase(db, { organizationId, email: '[email protected]' }); // changes nothing
if (preview.canErase) await eraseSubject(db, { organizationId, requestedBy: staff.userId, email: '[email protected]' });

Export returns one document with the person's bookings, the messages sent about them, waitlist entries, package purchases, recurring series, and the queued webhook events that carry their details. The links and tokens that let someone act as the person are left out: the self-service link and waitlist link are not exported, and the self-service and calendar links inside message bodies, and the offer link inside a waitlist event, are replaced with [removed].

Erase keeps what the business needs to keep its books and removes the person from it. Bookings, package purchases and recurring series stay as records with the name "Erased client" and the address [email protected]; the phone, client id, notes, staff notes, intake answers and cancellation reason are cleared, and every booking gets a new self-service link so old links stop working. Times, types, calendars, status, payment amounts and ids, and the meeting link, location and outside-calendar event ids stay (a host that puts a name in a location or meeting link should clear it itself). Messages, waitlist entries and webhook events that mention the person are deleted. It runs in one transaction: it all happens or none of it does.

Erase is refused (SCHED_1301) while the person has an upcoming pending or confirmed booking, or a waitlist entry that holds or is taking an offer. Cancel those first; the refusal says how many there are. previewErase also warns when an active package still has unused sessions, because after erasure it can no longer be matched to the person.

Every export and erase is recorded in DataSubjectRequest with who asked, the counts, and a SHA-256 digest of each identifier (tied to the organization), never the address or name. A digest can be recomputed by anyone who already knows the address, so it shows that a request was made for that address; it is not anonymous. If the record cannot be written, an export returns nothing.

Not covered, because the package does not hold it: the host's own client record, events already created in Google or Outlook calendars (they carry the client's name), the payment provider's records, webhook events already delivered to a receiver, database backups, and logs. Queued webhook events are found by the person's email address in the event text, so an event that names someone without their address is not found. A booking made in another organization under the same address is a different business's record and is never touched.