@eco-foundation/eco-message-contracts
v2.2.1
Published
Message envelope, contract definitions, and payload schemas for Eco AMQP messaging
Keywords
Readme
@eco-foundation/eco-message-contracts
The message envelope, defineContract(), and the live cross-service payload schemas for Eco's AMQP
bus. Zero AMQP dependency — install this alone if you only need to define, publish-shape, or
parse contracts. The transport lives in
@eco-foundation/eco-messaging, which
declares this package as a peer.
pnpm add @eco-foundation/eco-message-contractsRequires zod (^3.25 || ^4) as a peer. Note the 3.25 floor: a service declaring an older range
must raise the declared range even if its lockfile already resolves something newer.
What a contract is
A contract binds a payload schema to its routing, its deterministic identity, and its publication policy in one place, so those four facts cannot drift apart across the services that share the event.
import * as z from 'zod/v4';
import { defineContract } from '@eco-foundation/eco-message-contracts';
export const InventorySnapshot = defineContract({
eventType: 'inventory.snapshot',
routingKey: 'inventory.snapshot',
bindingPattern: 'inventory.snapshot',
schema: z.object({
aggregateId: z.string().min(1),
snapshotVersion: z.number().int().nonnegative(),
actualBalance: z.string(), // decimal amount in base units, as a string wire type
}),
schemaVersion: 1,
// Deterministic identity: the SAME logical event always resolves to the same messageId,
// which is what makes an outbox retry safe rather than a duplicate.
identity: (p) => `${p.aggregateId}:${p.snapshotVersion}`,
// Optional: enforce apply-in-order-within-scope. A stale or out-of-order redelivery is
// rejected before the handler runs, rather than being left to regress state.
ordering: {
field: 'snapshotVersion',
version: (p) => p.snapshotVersion,
scope: (p) => p.aggregateId,
},
delivery: 'required',
});delivery may be static or payload-dependent, and resolvePublicationPolicy(validatedPayload) is
the single admission decision derived from it. A required contract must also declare
durability: 'caller-ledger' to be publishable directly — otherwise it is refused before it reaches
the broker, because a persist-before-publish rule any call site can bypass silently is not a rule.
Declared field names are validated against the schema at definition time, so a typo in
digestExcludes or an ordering.field that does not exist is a module-load failure rather than a
silently-ineffective declaration.
Every static routing key is also exposed as routingKeyTemplate. For a payload-derived key, add an
explicit template such as <chainId>.inventory.snapshot; the built-in catalog requires one for
every contract and publishes it in the generated schema index for compatibility checks.
The envelope
buildEnvelope, readEnvelope, envelopeToHeaders, and the ENVELOPE_HEADERS constants are the
canonical envelope surface. Every message carries a message ID, causation and correlation IDs, event
type, schema version, source service, attempt count, and W3C trace context.
readEnvelope returns a discriminated result rather than throwing, so a malformed envelope is a
value your consumer can park deliberately instead of an exception in a hot path.
Live contracts
Eight contracts ship with the package, exported as ALL_CONTRACTS and described by
BALANCE_EVENTS_CATALOG (which records the producer and consumers of each, so the catalog answers
"who breaks if I change this" without a code search):
| Event type | Binding pattern |
| ------------------------------ | ------------------------------- |
| withdrawal.created | *.withdrawal.created |
| withdrawal.resolved | *.withdrawal.resolved |
| withdrawal.refunded | *.withdrawal.refunded |
| gateway-deposit.created | *.gateway-deposit.created |
| gateway-burn.mint-confirmed | *.gateway-burn.mint-confirmed |
| gateway-burn.burn-observed | *.gateway-burn.burn-observed |
| rebalance.snapshot | rebalance.snapshot |
| solver.actual-balance.update | solver.actual-balance.update |
Breaking payload versions
Breaking versions use real parsers, not a header allowlist. Define each historical schema with
defineVersionAdapter(schema, upcast) under legacyVersions; consumers call
parseVersion(envelope.schemaVersion, body) and handlers receive only the canonical current shape.
payloadDigest
payloadDigest(contract, payload) answers "does this publish carry anything a consumer can actually
observe?" — for a producer whose version bump is unconditional and which would otherwise re-emit a
terminal snapshot under a fresh version. Exclusions are declared per-contract via digestExcludes,
apply at the root only, and arrays are never sorted (order is part of identity). Values JSON
cannot faithfully represent are refused with the offending path named rather than silently mangled.
For non-TypeScript consumers
The tarball ships versioned, language-neutral JSON Schema artifacts, so a service in another language pins the published package (or vendors the files) instead of reimplementing the schemas:
@eco-foundation/eco-message-contracts/schema→schema/generated/index.json, the contract and envelope index with per-version schema paths.@eco-foundation/eco-message-contracts/conformance→ machine-readable conformance clauses, the AMQP wire mapping, and replay-verified delivery vectors.
The schemas deliberately do not reject unknown fields: a consumer must tolerate a field added by a newer producer, so validation is not a compatibility trap.
Documentation
These packages are the reference implementation of the standard, not its definition. The definition is two language-agnostic documents that a non-TypeScript service can implement against — read them before changing envelope headers, queue topology, settlement semantics, or the durable-publish rule:
- Messaging conformance profile — the wire and the consumer.
- Persist-before-publish and rehydration — the producer.
- Contract versioning and ownership — which bump a payload change needs, and the consumer-first rollout order.
- Repository README — install matrix, peer ranges, and the full transport guide.
Changes are tracked in CHANGELOG.md.
