@xemahq/declarative-reconciler
v0.2.0
Published
Generic declarative reconciliation engine for computing and applying diffs between desired and observed state.
Readme
@xemahq/declarative-reconciler
This package is a pure, framework-agnostic runtime SDK
that depends only on Node built-ins (no @xemahq/*, no NestJS, no zod).
It is the generic engine behind "Xema-as-Code": it converges an actual set
toward a desired set by a stable natural key and a content hash, with a
retire pass for owned items that are no longer desired. It generalizes the
upsert-by-hash + retire-removed logic previously hand-written in each boot
seeder (e.g. agent-contribution.service.ts).
Usage
Implement a ReconcilerPort for your domain and drive it:
import { reconcile, stableHash, type ReconcilerPort } from '@xemahq/declarative-reconciler';
const port: ReconcilerPort<DesiredPortal, ActualPortal> = {
computeDesired: () => derivePortalsFromInstalledBiomes(orgId),
readActual: () => listPortals({ orgId, managedBy: 'seeder' }), // own rows only
hashOf: (d) => stableHash(d.spec),
appliedHashOf: (a) => a.appliedHash,
create: (d) => createPortal(d),
update: (d, a) => updatePortal(a.physicalId, d),
retire: (a) => archivePortal(a.physicalId),
};
const result = await reconcile(port);
// { created, updated, retired, unchanged, declined, entries }planReconcile(port)returns the diff with no side effects — use it forxema plan, dry-runs, and the frontend drift view.reconcile(port)plans then applies, fail-fast (any port error propagates).
Ownership scoping (only reconcile rows you own) lives inside readActual, so
the retire pass can never delete UI- or IaC-owned rows.
Declining an apply
create, update and retire may return ReconcileOutcome.Declined to tell
the engine the action did not happen. That is the adopt-or-decline shape:
the port goes to apply, finds the target already owned by someone else, and
correctly refuses to touch it.
create: async (d) => {
const existing = await findBySlug(d.slug);
if (existing && !adoptable(existing)) return ReconcileOutcome.Declined;
await createPortal(d);
},Declines land in result.declined instead of created/updated/retired, so
a pass that converges nothing stops reporting that it created something.
- Returning nothing still means applied. Only the exact
Declinedsentinel changes a count, so a port written before this existed behaves identically. - A decline is not an error — the pass continues, and
reconcilestill throws on anything a port actually throws. - One counter spans all three verbs: the question it answers is "did this pass converge?".
What a plan can and cannot promise
planReconcile reads computeDesired and readActual and nothing else — it
never calls create/update/retire. A decline is discovered only inside an
apply, against a row the plan cannot see: readActual is ownership-scoped by
contract, so a key held by a different owner is invisible to the diff and
surfaces as a Create.
So a plan's Create entries mean "N desired keys this reconciler owns no row
for" — not "N rows will exist after apply". A structurally-declined create
stays in every future plan, because nothing about the diff changes. Render a
plan as pending, never as will be created, and read a non-zero declined
from the last apply as "this plan cannot complete as-is".
result.entries is likewise the plan as computed: a declined create still
reads Create there. The counts, not the entries, record what happened.
