Skip to main content

Advanced reactor usage

This page covers the low-level IReactor interface and the internal components you can access through ReactorModule. Most developers should use IReactorClient (see IReactorClient API Reference) — the information here is for advanced scenarios such as:

  • Building custom tooling or infrastructure on top of the reactor
  • Working with consistency tokens for read-after-write guarantees
  • Subscribing directly to internal event bus events
  • Constructing a reactor with custom storage or executor configurations
  • Writing integration tests that need access to internals

IReactor vs IReactorClient

IReactorClient is a high-level wrapper around IReactor. The table below summarizes the key differences:

AspectIReactorIReactorClient
Write return valuesReturns JobInfo immediately (fire-and-forget)Waits for job completion, returns the final document
SigningCaller passes an ISigner explicitlyClient manages signing internally
Document lookupSeparate get(), getBySlug(), getByIdOrSlug() methodsSingle get(identifier) that accepts either
Children/parentsReturns string[] (document IDs only)Returns PagedResults<PHDocument> (full documents)
Convenience methodsBasic CRUDPlus: createEmpty(), rename(), moveRelationship(), deleteDocuments(), and client.drives.addFile()
SubscriptionsNot available (use the event bus directly)subscribe(search, callback, view?) for real-time document changes
Consistency tokensExplicit — pass tokens to reads after writesHandled internally by the client

When to use IReactor directly:

  • You need fire-and-forget job submission without waiting for completion
  • You want explicit control over consistency tokens
  • You are building infrastructure that manages its own signing
  • You need access to executeBatch() for multi-document atomic operations with dependency ordering

Building a reactor with ReactorBuilder

ReactorBuilder uses a fluent API to construct and wire all internal components.

import { ReactorBuilder, ChannelScheme } from "@powerhousedao/reactor";
import { ConsoleLogger } from "document-model";

const reactor = await new ReactorBuilder()
.withDocumentModels([todoListModule, invoiceModule])
.withLogger(new ConsoleLogger())
.withExecutorConfig({ maxConcurrency: 4, jobTimeoutMs: 30_000 })
.withWriteCacheConfig({ maxDocuments: 100, ringBufferSize: 10 })
.withMigrationStrategy("auto")
.withChannelScheme(ChannelScheme.CONNECT)
.build();

build() returns an IReactor. If you need access to internal components, use buildModule() instead — it returns an InProcessReactorModule containing the reactor plus all its internals (see ReactorModule).

Builder methods

MethodDescription
withDocumentModels(models)Register document model modules
withUpgradeManifests(manifests)Register upgrade manifests for document model versioning
withLogger(logger)Set the logger (defaults to ConsoleLogger)
withExecutorConfig(config)Configure maxConcurrency and jobTimeoutMs
withWriteCacheConfig(config)Configure maxDocuments and ringBufferSize for the write cache
withMigrationStrategy(strategy)Set to "auto" to run database migrations on build
withKysely(kysely)Provide a custom Kysely database instance (defaults to in-memory PGlite)
withQueue(queue)Provide a custom job queue (defaults to InMemoryQueue)
withEventBus(eventBus)Provide a custom event bus
withExecutor(executor)Provide a custom job executor manager
withReadModel(readModel)Register an additional read model
withReadModelFactory(factory)Register a factory that builds a pre-ready read model after the operation index, write cache, and processor-manager tracker exist
withReadModelCoordinator(coordinator)Provide a custom read model coordinator
withSync(syncBuilder)Enable synchronization with remote reactors
withChannelScheme(scheme)Set the sync channel scheme
withFeatures(features)Set feature flags
withSignatureVerifier(verifier)Set a signature verification handler
withJwtHandler(handler)Set a JWT handler for authentication
withDocumentModelLoader(loader)Set a custom document model loader
withDocumentModelSpecs(specs)Register document model specs the reactor (or its workers) resolves by package or file path
withDriveContainerTypes(types)Set the document types treated as drive containers
withInstrumentedPool(instrumentation)Register an externally-built pg.Pool so its metrics surface through pools
withShutdownHook(hook)Register an async cleanup hook to run during graceful shutdown
withSignalHandlers()Register OS signal handlers for graceful shutdown
withWorkerPool(config)Run jobs in a worker pool instead of in-process (see Storage and scaling)
withWorkerDbConfig(db)Postgres connection info forwarded to each worker
withWorkerSignatureVerifierSpec(spec)Factory spec each worker imports to build its signature verifier
withWorkerFactory(factory)Inject a custom worker factory (transport)
withProjectionShards(config)Run N sharded projection workers (see Storage and scaling)
withProjectionWorkerFactory(factory)Inject a custom projection worker factory

withProjectionShards (and worker-pool mode) require withWorkerDbConfig: the workers need connection info to open their own pools, and build() throws otherwise. See Storage and scaling for worker pools and projection shards, and Synchronization and remote drives for sync.

IReactor API

Reading documents

// By exact ID
const doc = await reactor.get<MyDocument>(docId);

// By slug
const doc = await reactor.getBySlug<MyDocument>("my-document");

// By either ID or slug (throws if ambiguous)
const doc = await reactor.getByIdOrSlug<MyDocument>(identifier);

// With consistency token for read-after-write
const doc = await reactor.get<MyDocument>(docId, undefined, token);

// Outgoing and incoming relationships (returns string[] of IDs, not full documents)
const childIds = await reactor.getOutgoingRelationships(parentId, "child");
const parentIds = await reactor.getIncomingRelationships(childId, "child");

// Search
const results = await reactor.find({ type: "powerhouse/todo-list" });

// Operations: keyed by scope, each a PagedResults<Operation>
// (unlike the client's getOperations, which returns a flat PagedResults)
const ops = await reactor.getOperations(docId);
const globalOps = ops["global"].results;

// Document model modules (the IReactor analogue of the client's
// getDocumentModelModules)
const models = await reactor.getDocumentModels(); // PagedResults<DocumentModelModule>

getDocumentModels(namespace?, paging?, signal?) returns Promise<PagedResults<DocumentModelModule>>. getOperations accepts optional view, OperationFilter, and paging, and returns Promise<Record<string, PagedResults<Operation>>> — index it by scope.

Writing documents

All write methods return JobInfo immediately — they do not wait for the job to complete.

// Create a document
const job = await reactor.create(document, signer);

// Execute actions
const job = await reactor.execute(docId, "main", actions);

// Load pre-existing operations (e.g., from sync)
const job = await reactor.load(docId, "main", operations);

// Delete
const job = await reactor.deleteDocument(docId, signer);

// Manage relationships
const job = await reactor.addRelationship(parentId, childId1, "child");
const job = await reactor.removeRelationship(parentId, childId1, "child");

create, deleteDocument, execute, load, executeBatch, and loadBatch accept an optional trailing meta?: Record<string, unknown> that is merged into the job's JobMeta. Use it for tracing or correlation.

Batch operations

executeBatch lets you submit multiple mutation jobs with dependency ordering. Jobs are executed in the order dictated by their dependsOn keys.

const result = await reactor.executeBatch({
jobs: [
{
key: "create-drive",
documentId: driveId,
scope: "global",
branch: "main",
actions: [createDriveAction],
dependsOn: [],
},
{
key: "add-document",
documentId: driveId,
scope: "global",
branch: "main",
actions: [addFileAction],
dependsOn: ["create-drive"], // Waits for drive creation
},
],
});

// result.jobs["create-drive"] and result.jobs["add-document"] are JobInfo objects

loadBatch is the load-side counterpart. It submits batches of pre-existing operations (e.g. from sync) with the same dependency ordering. A LoadJobPlan uses operations instead of actions, and adds externalDeps: string[] — pre-resolved job UUIDs from prior batches that are appended to the queue hint without plan-key resolution.

const result = await reactor.loadBatch({
jobs: [
{
key: "load-doc",
documentId: docId,
scope: "global",
branch: "main",
operations: incomingOperations,
dependsOn: [],
externalDeps: [],
},
],
});

// result.jobs["load-doc"] is a JobInfo object

Job tracking and shutdown

// Check job status
const info = await reactor.getJobStatus(jobId);
// info.status is PENDING | RUNNING | WRITE_READY | READ_READY | FAILED

// Graceful shutdown
const shutdown = reactor.kill();
// shutdown.isShutdown is true immediately
await shutdown.completed; // Resolves when all in-flight jobs finish

kill() returns a ShutdownStatus synchronously (it is not a Promise): { get isShutdown(): boolean; completed: Promise<void> }. isShutdown flips to true immediately; await shutdown.completed to block until in-flight jobs drain. Do not await reactor.kill().

Consistency tokens

Every write operation returns a JobInfo that includes a ConsistencyToken. This token captures the exact operation coordinates that the write produced:

type ConsistencyToken = {
version: 1;
createdAtUtcIso: string;
coordinates: Array<{
documentId: string;
scope: string;
branch: string;
operationIndex: number;
}>;
};

Pass this token to subsequent reads to guarantee you see the effects of your write:

const job = await reactor.execute(docId, "main", actions);
const token = job.consistencyToken;

// This read is guaranteed to include the operations from the write above
const doc = await reactor.get(docId, undefined, token);

Without a consistency token, reads may return stale data if the read models have not yet indexed the latest operations. IReactorClient handles this automatically — it waits for READ_READY before returning — but when using IReactor directly, consistency tokens give you explicit control.

ReactorModule

ReactorBuilder.buildModule() returns an InProcessReactorModule that exposes the full in-process dependency graph:

const module = await new ReactorBuilder()
.withDocumentModels([todoListModule])
.buildModule();

const { reactor, eventBus, processorManager, operationStore } = module;

The base ReactorModule interface has only three fields (documentModelRegistry, syncModule, eventBus) — the subset a client resolves regardless of where the reactor graph lives. buildModule() returns the in-process extension InProcessReactorModule, which adds the components below.

Available components

InProcessReactorModule:

ComponentInterfacePurpose
reactorIReactorThe reactor instance
documentModelRegistryIDocumentModelRegistryRegistered document model modules and version upgrades
eventBusIEventBusInternal pub/sub for reactor events
queueIQueueJob queue with per-document ordering
jobTrackerIJobTrackerTracks job lifecycle (PENDING through READ_READY/FAILED)
executorManagerIJobExecutorManagerManages job executor instances
operationStoreIOperationStoreAppend-only operation log
keyframeStoreIKeyframeStoreDocument state snapshots
writeCacheIWriteCacheWrite-path document cache
operationIndexIOperationIndexGlobal ordinal assignment
documentViewIDocumentViewMaintains document snapshots for reads
documentViewConsistencyTrackerIConsistencyTrackerTracks document-view indexing for read-after-write
documentIndexerIDocumentIndexerTracks document relationships (parent/child graph)
documentIndexerConsistencyTrackerIConsistencyTrackerTracks document-indexer indexing for read-after-write
readModelCoordinatorIReadModelCoordinatorDispatches operations to all read models
subscriptionManagerIReactorSubscriptionManagerManages document change subscriptions
processorManagerIProcessorManagerRoutes operations to user-defined processors
processorManagerConsistencyTrackerIConsistencyTrackerTracks processor-manager catch-up
databaseKysely<Database>The underlying database connection
syncModuleInProcessSyncModule | undefinedSync infrastructure (if configured)
poolsPoolInstrumentation[]Instrumented pg.Pool handles; empty for PGlite

Subscribing to the event bus

The IEventBus lets you listen to internal reactor events. Subscribers are called sequentially in registration order.

import { ReactorEventTypes, SyncEventTypes } from "@powerhousedao/reactor";

// Listen for all completed jobs
const unsubscribe = module.eventBus.subscribe(
ReactorEventTypes.JOB_READ_READY,
(type, event) => {
console.log("Job completed:", event.jobId);
console.log("Operations:", event.operations.length);
},
);

// Listen for sync failures
module.eventBus.subscribe(SyncEventTypes.SYNC_FAILED, (type, event) => {
console.error("Sync failed for job:", event.jobId, event.errors);
});

Besides ReactorEventTypes, SyncEventTypes, and QueueEventTypes, the executor managers emit JobExecutorEventTypes (JOB_STARTED: 20000, JOB_COMPLETED: 20001, JOB_FAILED: 20002, EXECUTOR_STARTED: 20003, EXECUTOR_STOPPED: 20004).

warning

Two distinct JobFailedEvent shapes exist. The reactor-level ReactorEventTypes.JOB_FAILED (10005) carries error: Error; the executor-level JobExecutorEventTypes.JOB_FAILED (20002) carries error: string. Check which enum you subscribed to before reading error.

See Reactor event system for the full list of event types.

Working with the operation store

The IOperationStore provides direct access to the append-only operation log.

const { operationStore } = module;

// Get operations since a specific revision
const ops = await operationStore.getSince(docId, "global", "main", 5);

// Get the latest revision per scope
const revisions = await operationStore.getRevisions(docId, "main");
warning

Writing directly to the operation store bypasses the job queue, reducers, and read models. In almost all cases, use reactor.execute() or reactor.load() instead. Direct store access is intended for read-only inspection, debugging, and testing.

Registering custom read models

A read model receives operations after each job's write phase completes and builds a derived view of the data.

import type { IReadModel } from "@powerhousedao/reactor";

class MyCustomReadModel implements IReadModel {
// `name` identifies the read model for lookup via getReadModel
readonly name = "my-custom-read-model";

async indexOperations(operations: OperationWithContext[]): Promise<void> {
for (const { operation, context } of operations) {
// Build your derived view
}
}
}

const reactor = await new ReactorBuilder()
.withDocumentModels([todoListModule])
.withReadModel(new MyCustomReadModel())
.build();

Read models registered via withReadModel() run in the pre-ready phase — they complete before JOB_READ_READY fires. This is in contrast to processors (registered via ProcessorManager), which run in the post-ready phase.

Example: integration test setup

A common use case for the low-level API is writing integration tests that need full control over the reactor lifecycle:

ReactorClientBuilder.buildModule() returns both the client and the reactorModule (the same InProcessReactorModule you get from ReactorBuilder.buildModule()). Hand it a ReactorBuilder via withReactorBuilder and it builds the reactor for you:

import { ReactorBuilder, ReactorClientBuilder } from "@powerhousedao/reactor";
import { ConsoleLogger } from "document-model";

async function createTestReactor() {
const builder = new ReactorBuilder()
.withDocumentModels([todoListModule])
.withLogger(new ConsoleLogger());

// buildModule returns both the client and the reactor module internals
const { client, reactorModule } = await new ReactorClientBuilder()
.withReactorBuilder(builder)
.buildModule();

return { client, reactorModule };
}

// In your test
const { client, reactorModule } = await createTestReactor();

// Use client for high-level operations
const doc = await client.createEmpty("powerhouse/todo-list");

// Use the reactor module for low-level inspection
const ops = await reactorModule.operationStore.getSince(
doc.header.id,
"global",
"main",
0,
);
expect(ops.results.length).toBe(1);

// Cleanup
reactorModule.reactor.kill();

If you already built a reactor and have its internals, wire them directly instead of passing a builder:

const client = await new ReactorClientBuilder()
.withReactor(
module.reactor,
module.eventBus,
module.documentIndexer,
module.documentView,
)
.build();

ReactorClientBuilder methods

MethodDescription
withLogger(logger)Set the logger (defaults to ConsoleLogger)
withReactorBuilder(builder)Build the reactor from a ReactorBuilder (mutually exclusive with withReactor)
withReactor(reactor, eventBus, documentIndexer, documentView)Wire an already-built reactor and its internals
withSigner(config)Set an ISigner or SignerConfig for signing/verification
withSubscriptionManager(manager)Provide a custom subscription manager
withJobAwaiter(awaiter)Provide a custom job awaiter
withDocumentModelLoader(loader)Set a custom document model loader (forwarded to the reactor builder)
build()Build and return the IReactorClient
buildModule()Build and return the client plus its reactor module internals

You must call exactly one of withReactorBuilder or withReactor before build()/buildModule(), or the build throws.