Skip to content

Events ​

discendo-sdk/core exports EventBus, a typed publish/subscribe bus that allows your application to react to domain events—such as student enrollments, grade postings, or schedule cancellations—without tying the SDK to specific background workers, message queues, or notification providers.


Quick Start ​

ts
import { EventBus } from "discendo-sdk/core";
import { EnrollmentService } from "discendo-sdk/enrollment";
import { GradingService } from "discendo-sdk/grading";

const bus = new EventBus({
  onHandlerError: (err, event) => {
    logger.error({ err, eventType: event.type });
  },
});

// Pass the same bus to all services that emit events
const enrollment = new EnrollmentService(repos, bus);
const grading = new GradingService(gradeRepo, bus);

// Subscribe to a specific event (type-safe payload)
bus.on("enrollment.enrolled", async (event) => {
  // event is narrowed to EnrolledEvent
  console.log(
    `User ${event.userId} enrolled in ${event.sectionId} (status: ${event.status})`,
  );
  await sendWelcomeEmail(event.userId, event.sectionId);
});

// Subscribe to all events using wildcard '*'
bus.on("*", (event) => {
  auditLogger.log(event.type, event);
});

The EventBus API ​

ts
export class EventBus {
  constructor(options?: {
    onHandlerError?: (error: unknown, event: LmsEvent) => void;
  });

  // Specific event listener: returns an unsubscribe function
  on<T extends LmsEvent["type"]>(
    type: T,
    handler: (event: LmsEventOfType<T>) => unknown,
  ): () => void;

  // Wildcard listener for all events
  on(type: "*", handler: (event: LmsEvent) => unknown): () => void;

  // Runs once and auto-unsubscribes
  once<T extends LmsEvent["type"]>(
    type: T,
    handler: (event: LmsEventOfType<T>) => unknown,
  ): () => void;
  once(type: "*", handler: (event: LmsEvent) => unknown): () => void;

  // Emit an event (invoked by services)
  emit(event: LmsEvent): Promise<void>;
}

Delivery Guarantees ​

The EventBus provides six specific operational guarantees:

1. Execution Order ​

When emit(event) runs, handlers registered for that exact event type are started first, followed by wildcard * handlers, in order of registration. Async handlers run concurrently via Promise.allSettled.

2. Failure Isolation ​

A handler that throws an error (or returns a rejected Promise) never cancels other handlers and never causes emit() to reject. A buggy email notification hook cannot abort an enrollment transaction.

3. Error Logging ​

Because the SDK does not bundle a logger, unhandled exceptions in listeners are captured and passed to the onHandlerError callback provided in the constructor.

4. Audience Snapshot ​

The list of listeners for an event is snapshotted when emit() begins. A listener that subscribes or unsubscribes during execution cannot alter the recipients of the event currently being delivered.

5. Return Values ​

Handlers may return any value or Promise. Only Promises are awaited.

6. Single Event Reference ​

All handlers receive the identical event object reference. Treat event payloads as strictly read-only.


Fire-and-Forget Architecture ​

Inside SDK services, events are dispatched asynchronously:

ts
void this.events?.emit({
  type: "enrollment.enrolled",
  enrollmentId: enrollment.id,
  userId: enrollment.userId,
  sectionId: enrollment.sectionId,
  status,
});

Implications for Host Applications: ​

  • Zero Latency Overhead: Slow email delivery or webhook calls in handlers will never delay the HTTP response returned by the service method.
  • At-Most-Once Delivery: Events are held in-memory. If the server process crashes immediately after a database write, pending event handlers will not run. If guaranteed delivery is mandatory for your compliance needs, use a transactional outbox pattern in your repository.
  • Testing: In automated tests, await service.enroll(...) resolves before event handlers finish. If your test asserts on event outcomes, await a Promise resolved inside your listener.

Event Catalog ​

All events belong to the LmsEvent discriminated union, keyed on type:

CategoryEvent TypeTriggerPayload Summary
Enrollmentenrollment.enrolledNew enrollment createdenrollmentId, userId, sectionId, status, orgId?
enrollment.droppedStudent dropped from sectionenrollmentId, userId, sectionId, orgId?
enrollment.promotedWaitlisted student promoted to activeenrollmentId, userId, sectionId, trigger ('manual' | 'auto'), orgId?
enrollment.promotionFailedBackground auto-promotion encountered errorsectionId, droppedEnrollmentId, error, orgId?
enrollment.requestedStudent submitted seat requestrequestId, userId, sectionId, orgId?
enrollment.requestDecidedSeat request accepted, rejected, or withdrawnrequestId, decision, reviewerId?, enrollmentId?
Gradinggrading.gradePostedScore recorded or supersededgradeEntryId, submissionId, userId, score, maxScore, graderId, orgId?
Contentcontent.nodeCreatedPage, assignment, or quiz addednodeId, sectionId, kind, title, orgId?
content.nodePublishedNode visibility toggled to publishednodeId, sectionId, version, orgId?
content.nodesReorderedStructural sequence modifiedsectionId, orderedIds, orgId?
Assessmentassessment.submittedAssignment submission uploadedsubmissionId, contentId, userId, attemptNumber, orgId?
assessment.quizAttemptStartedQuiz attempt started with generated questionsattemptId, quizId, userId, attemptNumber, orgId?
assessment.quizAttemptSubmittedQuiz attempt finished and scoredattemptId, score, autoScore, needsGrading, orgId?
Schedulingscheduling.occurrenceCancelledSpecific date session cancelledoccurrenceId, reason, cancelledBy?, orgId?
scheduling.occurrenceRescheduledSession moved to new time/roomoccurrenceId, oldDate, newDate, roomId?, orgId?
Delegationdelegation.grantedInstructor delegated role to TAgrantId, instructorId, taId, actions, sectionId, orgId?
delegation.revokedTA grant revokedgrantId, revokedBy, orgId?
Guardiansguardians.linkCreatedParent-ward link createdlinkId, guardianId, wardId, scopes, orgId?
guardians.linkRevokedParent access revokedlinkId, revokedBy, orgId?

Bridging to Notifications ​

The discendo-sdk/communication package includes a built-in adapter to route domain events into a notification channel (email, SMS, push):

ts
import { bridgeEventBusToNotificationSink } from "discendo-sdk/communication";

bridgeEventBusToNotificationSink(bus, myNotificationSink, {
  resolveContentId: async (submissionId) => {
    const sub = await db.submissions.findById(submissionId);
    return sub?.contentId ?? null;
  },
});

Next Steps ​

  • Learn how permissions gate actions in Permissions.
  • Understand multi-tenant event filtering in Tenancy.

Released under the GPL-3.0 License.