Skip to main content

Commands Framework

The commands framework packages complex write flows into discrete handlers. Commands run inside the request-scoped DI container, emit audit logs, and can capture before/after snapshots for undo.

Key Concepts

  • Commands live under packages/<package>/src/modules/<module>/commands/*.ts.
  • Each handler exports a unique id and registers itself by calling registerCommand(handler) when the module boots (see index.ts).
  • Commands execute through the shared CommandBus, resolved from the request container: container.resolve<CommandBus>('commandBus').
  • Action logs are generated automatically when handlers opt-in via buildLog, and undo tokens are supported for reversible operations.
  • Helper utilities (parseWithCustomFields, emitCrudSideEffects, etc.) reduce boilerplate when working with CRUD entities and custom fields.

Handler Anatomy

import type { CommandHandler } from '@open-mercato/shared/lib/commands'

const createUserCommand: CommandHandler<CreateUserInput, User> = {
id: 'auth.users.create',
async execute(input, ctx) {
// Main mutation logic — runs inside the request container.
},
async prepare(input, ctx) {
// Optional: capture "before" snapshots (e.g., load current record state).
},
async captureAfter(input, result, ctx) {
// Optional: return a serialized post-mutation snapshot for audit logs.
},
async buildLog({ input, result, ctx, snapshots }) {
// Optional: return metadata for ActionLogService (labels, resource ids, etc.).
},
async undo({ input, ctx, logEntry }) {
// Optional: implement reversible operations (requires `isUndoable !== false`).
},
}

Handler methods are all optional except id and execute. Use the optional hooks when the flow needs richer auditing or undo support. prepare and captureAfter return arbitrary snapshot payloads that are later fed into buildLog.

Registration

Modules import their command files from index.ts so handlers are registered at bootstrap:

// packages/core/src/modules/auth/index.ts
import './commands/users'
import './commands/roles'

export const metadata = { /* ... */ }

Inside the command file, call registerCommand(handler) once per handler:

import { registerCommand } from '@open-mercato/shared/lib/commands'

registerCommand(createUserCommand)
registerCommand(updateUserCommand)
registerCommand(deleteUserCommand)

Attempting to register the same id twice throws an error, helping catch collisions early.

Executing Commands

The request container exposes a singleton CommandBus. Any server entry point with container access (API routes, backend actions, CLI commands, subscribers) can execute a command:

import type { CommandBus } from '@open-mercato/shared/lib/commands'

const commandBus = container.resolve<CommandBus>('commandBus')

await commandBus.execute('auth.users.create', {
input: { email, password, organizationId },
ctx: {
container,
auth,
organizationScope,
selectedOrganizationId,
organizationIds,
request,
},
})

CommandExecutionOptions carries the validated payload plus the runtime context. The context mirrors the request lifecycle, so handlers can pull collaborators (em, dataEngine, services) from the same container instance.

Undo Flow

If a handler implements undo and does not set isUndoable to false, the bus will emit an undoToken in the action log metadata. Later, call commandBus.undo(token, ctx) to reverse the operation. Handlers receive the original input and the persisted log entry, enabling complete restoration.

Auditing & Snapshots

Handlers can opt into structured audit logs by returning metadata from buildLog. The bus merges metadata from the handler, the execution options, and snapshots:

  • prepare → save the "before" snapshot.
  • captureAfter → computed "after" snapshot (often a serialized entity).
  • buildLog → report labels, resource identifiers, and change deltas.

When metadata is provided, the bus writes to ActionLogService (if registered in the container) and persists undo tokens automatically. Snapshot fields (snapshotBefore / snapshotAfter) make it easy to render diffs in the admin UI.

Helper Utilities

Reusable helpers in @open-mercato/shared/lib/commands/helpers cover common patterns:

  • parseWithCustomFields(schema, raw) splits standard properties from custom field payloads and validates with Zod.
  • setCustomFieldsIfAny(...) persists custom field values after entity mutations.
  • emitCrudSideEffects(...) dispatches CRUD events and upserts index payloads.
  • buildChanges(before, after, keys) produces diff metadata for audit logs.
  • requireTenantScope(...) / requireId(...) enforce multi-tenant and identity checks.

These helpers keep command files compact while respecting module isolation and extensibility rules.

Testing Commands

Command handlers are ordinary functions and can be tested in isolation. The shared test helpers (packages/shared/src/lib/commands/__tests__/command-bus.test.ts) demonstrate mocking the registry and executing commands through a new CommandBus(). For integration-level tests, instantiate the request container, register the command handler, and execute it via the bus to ensure DI wiring, logging, and undo all work together.

Module Guidelines

  • Keep commands module-scoped; do not call into other modules directly. If cross-module data is required, fetch via IDs using injected services.
  • Validate inputs with Zod schemas located next to the entity (data/validators.ts) and reuse them inside commands.
  • Always propagate tenant/organization context when mutating tenant-scoped entities.
  • Expose command-triggered features in the module's acl.ts so UIs and APIs can require the right permissions.

Following these practices keeps the command layer consistent, discoverable, and safe to extend as new modules plug into the platform.