Skip to content

@memberjunction/work-queue-core

Transport-neutral contracts and runtime for the MemberJunction durable work queue. No MemberJunction runtime dependencies — Lambda and other external consumers import only this package (plus a transport package such as @memberjunction/work-queue-aws).

Design and contract: 02-implementation-overview.md and 03-interfaces-and-tables.md under plans/work-queue-1/ on the spec/work-queue branch; the handler author’s rules are in guides/WORK_QUEUE_CONSUMER_GUIDE.md.

AreaExports
Envelope & publishingWorkMessage, PublishRequest, PublishResult, IWorkPublisher, ValidatePublishRequest, BuildWorkMessage, CanonicalEnvelope, PublishErrorCodes
HandlersWorkHandler, WorkContext, Outcome, FatalWorkError, TransientWorkError
Policy & rulesSubscriptionPolicy, ParseSubscriptionFilter, ValidateSubscriptionFilter, MatchesFilter, FilterFields, WORK_QUEUE_FILTER_SUPPORT, ComputeBackoffSeconds, HeartbeatIntervalSeconds, SubscriptionUnsupportedReason, FilterUnsupportedReason
TransportsITransportDriver, ITransportConsumer, ITransportOperator, TransportCapabilities
RuntimeConsumerRuntime (loop or ProcessBatch), DeliveryExecution
Reference transportInMemoryTransport (Database-transport semantics, in memory)
REST clientWorkQueueApiPublisher, REST mapping helpers
Testing (/testing)RunConformanceChecks, CONFORMANCE_CASES, ConformanceAssertionError, BuildTopicBinding, BuildSubscriptionBinding, ManualClock
Testing with vitest (/testing/vitest)RunTransportConformanceSuite
import { FatalWorkError, Outcome, type WorkContext, type WorkHandler, type WorkMessage } from '@memberjunction/work-queue-core';
interface UnsubscribePayload { email: string; occurredAt: string }
export class UnsubscribeHandler implements WorkHandler<UnsubscribePayload> {
public async Handle(message: WorkMessage<UnsubscribePayload>, context: WorkContext) {
if (!message.Payload?.email) {
throw new FatalWorkError('email missing'); // dead-letter immediately
}
await suppress(message.Payload.email, message.MessageID); // idempotent by MessageID
return Outcome.Complete(); // throw anything else → retry with backoff
}
}

The queue guarantees durable delivery, one valid lease holder while your handler runs, and fencing. Everything else is yours — see guides/WORK_QUEUE_CONSUMER_GUIDE.md. Four rules cover most handlers:

  1. Long awaits need no heartbeat code. Keep HeartbeatMode: 'Auto' (the default) and set MaxProcessingSeconds to the longest a healthy run should take: the runtime renews the lease while your handler awaits, and aborts once the cap passes. Use 'Manual' only when you want a stuck handler detected, then call await context.Heartbeat({ Percent }) at real progress boundaries.
  2. Honor context.Signal. It aborts when an operator cancels the item, the lease is lost, the processing cap is hit, or the host shuts down; context.Signal.reason says which ('Cancelled', 'LeaseLost', 'MaxProcessingSeconds', 'Shutdown'). Stop whatever external work you started — a child process, a remote job, a long query. Stopping promptly matters: after a cancel, the runtime acknowledges it as soon as your handler returns, which frees an Exclusive/Ordered key immediately instead of at lease expiry.
  3. Never block the event loop. A long synchronous CPU-bound loop stops the heartbeat timer with it and the lease expires under you; move that work to a worker thread or child process.
  4. Non-restartable side effects need your own guard. At-least-once delivery means a handler can run twice. Make handlers idempotent (message.MessageID is stable across redeliveries and replays), and for work that must not overlap — an infrastructure apply, a financial posting — set a LeaseSeconds larger than your worst heartbeat outage and hold a domain lock of your own.

context.Heartbeat resolves false once the lease is gone or the delivery is cancelled; a transient transport error does not — it is retried on the next tick. The runtime heartbeats every min(LeaseSeconds / 3, 30 s), so a cancel or a lost lease is noticed within 30 seconds however long the lease, and it enforces the lease’s expiry on its own timer in both heartbeat modes — a Manual handler that stops heartbeating is aborted when its lease runs out.

Ordering is publish order. There is no producer-supplied ordering number. If one producer owns a key’s order, it publishes in order: do not publish N+1 until N is accepted, or publish both in one call. Ordered subscriptions need a topic on the Database transport.

A filter is MJ’s standard CompositeFilterDescriptor JSON over envelope attributes — the shape mj-filter-builder edits and user views persist — restricted to what every transport can express:

{ "logic": "and", "filters": [
{ "field": "eventType", "operator": "eq", "value": "click" },
{ "logic": "or", "filters": [
{ "field": "tenant", "operator": "eq", "value": "acme" },
{ "field": "tenant", "operator": "eq", "value": "globex" } ] }
] }

Operators: eq, neq, startswith, isnull, isnotnull. The root is and; a nested group may only be a single-field OR of eq; and each field may be constrained only once (brokers read a field’s values as OR, so two AND-ed rules on one field would widen the filter rather than narrow it). Matching is case-sensitive (brokers match exactly, so MJ and the broker agree), which is the one deliberate divergence from CompositeFilter’s case-insensitive comparison. Richer filters are rejected by ValidateSubscriptionFilter / SubscriptionUnsupportedReason when a subscription is saved, rather than silently delivering everything.

import { WorkQueueApiPublisher } from '@memberjunction/work-queue-core';
const publisher = new WorkQueueApiPublisher({ BaseUrl: 'https://api.example.com/work-queue', ApiKey: process.env.MJ_API_KEY ?? '' });
const results = await publisher.Publish('email.events', [
{ PartitionKey: 'alice@example.com', Attributes: { eventType: 'unsubscribe' }, Payload: { email: 'alice@example.com', occurredAt: '2026-09-16T12:00:00Z' }, DeduplicationKey: 'sg:evt-123' },
]);
// Every result is Accepted, Duplicate or Rejected; retry Rejected items whose Error.Retryable is true.
import { BuildTopicBinding, BuildSubscriptionBinding } from '@memberjunction/work-queue-core/testing';
import { RunTransportConformanceSuite } from '@memberjunction/work-queue-core/testing/vitest';
RunTransportConformanceSuite('MyTransport', {
Capabilities: MY_CAPABILITIES,
Traits: { ReleaseConsumesAttempt: false, ExpiredLeaseDeadLetters: true, ReceiveWaitSeconds: 0 },
CreateDriver: async () => new MyTransport(),
CreateTopic: async (_driver, name, overrides) => BuildTopicBinding(name, overrides),
CreateSubscription: async (_driver, topic, name, overrides) => BuildSubscriptionBinding(topic, name, overrides),
AdvanceTime: async (ms) => clock.Advance(ms),
});

Outside vitest (for example an integration runner against a live database), call await RunConformanceChecks(harness) from /testing: it runs the same 26 cases sequentially, never throws, and returns { Id, Title, Status: 'Passed' | 'Failed' | 'Skipped', Detail, DurationMs } per case.

vitest is an optional peer dependency used only by /testing/vitest.

  • No @memberjunction/* dependency or import, and no runtime dependencies — enforced by src/__tests__/dependencyGuard.test.ts.
  • cd packages/WorkQueue/core && pnpm test and pnpm run build.