@bernierllc/content-flow-adapter
v0.2.1
Published
Optional bridge that mirrors content items and editorial stage changes into @bernierllc/flow-engine flows — kanban visualization, flow analytics, and Nevar automation for the content manager with zero hard coupling
Readme
@bernierllc/content-flow-adapter
Optional bridge that mirrors content items and editorial stage changes into @bernierllc/flow-engine flows.
Install it and your content pipeline shows up as a flow: kanban visualization via flow-board-ui, analytics via flow-metrics, automation via Nevar triggers. Don't install it and nothing changes — the content stack has zero dependency on the flow stack, and vice versa. Both sides are injected through duck-typed ports, so the adapter is the only place the two domains meet.
Install
npm install @bernierllc/content-flow-adapter @bernierllc/flow-engine @bernierllc/flow-storage@bernierllc/flow-engine (and the content packages) are optional peer dependencies — you bring whichever sides you use.
Quick start
import { createFlowEngine } from '@bernierllc/flow-engine';
import { InMemoryFlowStorage } from '@bernierllc/flow-storage';
import { createContentFlowAdapter } from '@bernierllc/content-flow-adapter';
const engine = createFlowEngine({ storage: new InMemoryFlowStorage() });
await engine.start();
const adapter = createContentFlowAdapter({ flowEngine: engine });
await adapter.initialize(); // registers with NeverHub (if present) + ensures the flow definition
// Wire your content event sources (both optional, both detachable):
const detachWorkflow = adapter.attachWorkflowService(contentWorkflowService);
const detachCpm = adapter.attachContentProjectManager(contentProjectManager);From then on, stage_transitioned events mirror into the flow (unknown content is auto-tracked), content:added starts tracking, content:removed archives. You can also drive it manually:
await adapter.trackContent({ id: 'post-1', title: 'Launch post', contentType: 'blog-post' });
await adapter.syncContentStage('post-1', 'review', { actorId: 'editor-1', actorType: 'user' });
const status = await adapter.getContentFlowStatus('post-1'); // { item, history }
await adapter.untrackContent('post-1'); // archives the flow itemBidirectional mode (board moves → content system)
const adapter = createContentFlowAdapter({
flowEngine: engine,
syncDirection: 'bidirectional',
onFlowTransition: async (contentId, toStageKey, record) => {
await contentWorkflowService.transitionStage({
contentId,
targetStageId: toStageKey,
userId: record.actorId,
});
},
});
// feed flow-side transitions (e.g. from a board UI) into:
await adapter.handleFlowTransition(record);Echo suppression is built in: the adapter stamps its own writes with metadata.source = '@bernierllc/content-flow-adapter' and ignores them coming back, plus a time-window guard for unstamped echoes. Genuine ping-pong raises SYNC_LOOP_DETECTED.
The default flow
createDefaultContentFlowDefinition() mirrors the standard editorial lifecycle:
draft → review → approved → scheduled → publishing → published (terminal)
review → draft (reject) · publishing → failed → publishing (retry) · archivedStage keys match the content stack's status vocabulary (ContentStatus + ContentState), so the default DEFAULT_CONTENT_STAGE_MAPPING is an identity map. Override either:
createContentFlowAdapter({
flowEngine: engine,
flowDefinition: myCustomCreateFlowInput, // replace the whole flow
mapping: { stages: { live: 'published' }, fallbackStageKey: 'draft' },
});Ports (duck-typed)
| Port | Satisfied by | Used for |
|------|-------------|----------|
| FlowEngineLike | @bernierllc/flow-engine's FlowEngine | all flow operations |
| WorkflowEventSourceLike | @bernierllc/content-workflow-service (onEvent/offEvent) | mirroring stage transitions |
| ContentEventEmitterLike | @bernierllc/content-project-manager (extends @bernierllc/event-emitter) | track/untrack on add/remove |
| ContentRef | client Content, WorkflowContent, or any { id } | tracking input |
Error Handling
All errors are ContentFlowAdapterError with ES2022 Error.cause chains.
| Class | Code | Description | Retryable |
|-------|------|-------------|-----------|
| ContentFlowAdapterError | CONTENT_FLOW_ADAPTER_ERROR | Generic/wrapped engine failure | Depends on cause |
| ContentFlowAdapterError | FLOW_NOT_INITIALIZED | Used before initialize() | No — call initialize() |
| ContentFlowAdapterError | CONTENT_NOT_TRACKED | Sync/untrack on unknown content | No — trackContent() first |
| ContentFlowAdapterError | STAGE_MAPPING_ERROR | Unmapped stage, no fallback | No — fix mapping |
| ContentFlowAdapterError | TRANSITION_FAILED | Engine or callback rejected the transition | Sometimes — check cause |
| ContentFlowAdapterError | SYNC_LOOP_DETECTED | Ping-pong between the two systems | No — fix wiring |
| ContentFlowAdapterError | INVALID_CONFIG | Bad constructor config | No |
Event-driven mirroring (attach*) is fire-and-forget: failures are logged, never thrown — the same precedent as flow-engine's Nevar emission.
NeverHub
Auto-detected at initialize() (standard NEVERHUB_* env vars). Fully functional without it.
License
SEE LICENSE IN LICENSE — Copyright (c) 2025 Bernier LLC. Limited-use license.
