@xemahq/object-projection-nest
v0.6.3
Published
Shared NestJS building blocks for the XSI plane-1 object projection surface (`GET /describe-objects` + the `xema.object-registry.projection.published.v1` CloudEvent) that every XemaObject-owning service implements.
Readme
@xemahq/object-projection-nest
This package belongs to Layer 1 — it is a kernel-side NestJS SDK with zero domain knowledge and no dependency on any Xema service.
It ships the shared implementation of the XSI plane-1 object projection
surface that every XemaObject-owning service exposes so
object-registry-api can build the platform-wide object index:
| piece | export |
| --- | --- |
| GET /describe-objects wire DTOs | DescribeObjectsResponseDto, XemaObjectResponseDto, XemaSpaceRefDto, XemaSubjectRefDto |
| CloudEvent descriptor factory | defineObjectProjectionEvents(serviceName) |
| change-driven publisher with reconcile-on-boot | ObjectProjectionPublisherService |
| the slice content digest it compares | projectionEnvelopeDigest(envelope) |
Freshness model: reconcile on boot, publish on change
The publisher pushes the COMPLETE slice unconditionally at boot and thereafter only when the slice has actually changed.
- boot — always publishes. A fresh process knows nothing about what the registry currently holds for its source, so this is what repairs a slice whose publish was dropped or failed before the last restart.
- every
PROJECTION_REFRESH_INTERVAL_MS— builds the envelope, digests it locally, and publishes only if the digest differs from the last successfully published one. In a quiet hour this puts nothing on the bus. scheduleRepublish()— an optional per-write hook that collapses detection latency from one interval toPROJECTION_DEBOUNCE_MS. It passes the same digest gate, so a write that projects to identical content still publishes nothing. Wiring one can only ever make a producer more responsive, never noisier.publishProjection(Manual)— forced, like boot. "Force a resync" that silently did nothing would be worse than the republish it saves.
The interval is a POLL, not a heartbeat, and it is deliberately still here: most producers in the fleet wire no per-write hook, so for them it is the change signal. Deleting it would leave them publishing at boot and never again — the registry's cache would go stale on every write with nothing anywhere reporting a failure.
The baseline is per PROCESS and in memory. Two replicas each publish once per real change instead of once a minute each; the consumer applies an idempotent REPLACE, so the duplicate costs nothing.
Producer shape
A producing service adds one describe-objects/ folder:
// projection-events.registry.ts
export const { descriptor: ObjectRegistryProjectionPublishedEvent, descriptors } =
defineObjectProjectionEvents('knowledge-base-api');
// describe-objects.service.ts — builds the COMPLETE current slice
@Injectable()
export class DescribeObjectsService {
async buildEnvelope(): Promise<DescribeObjectsResponseDto> { /* … */ }
}
// projection-publisher.service.ts — PUSH path (optional; PULL alone is valid)
@Injectable()
export class ProjectionPublisherService extends ObjectProjectionPublisherService {
constructor(
publisher: DescriptorEventPublisherService,
describeObjects: DescribeObjectsService,
) {
super(publisher, {
source: KNOWLEDGE_BASE_PROJECTION_SOURCE,
descriptor: ObjectRegistryProjectionPublishedEvent,
buildEnvelope: () => describeObjects.buildEnvelope(),
});
}
}Layout note
Sources live under src/projection/ (not src/lib/) and the package declares a
./* subpath export. This is load-bearing, not cosmetic: the @nestjs/swagger
CLI plugin emits a require() of a DTO's physical declaration path, which
tooling/codegen/scrub-swagger-plugin-paths.mjs rewrites to
@xemahq/<pkg>/<first-dir-under-dist>. If that directory is not a resolvable
subpath export, every consuming pod crashes at boot with
ERR_PACKAGE_PATH_NOT_EXPORTED. Same convention as
@xemahq/kernel-contracts. Consumers import from the package root.
Invariants
- A projection is always the COMPLETE slice. The registry REPLACES its
cache slice keyed by
sourceatomically; there is no delta primitive. payloadis opaque on the wire. The per-kind payload schema is owned by the producing service's domain contract; plane 1 carries it through and plane 3 (Capability) is the validation point.- A row that cannot be projected (missing scope qualifier, missing version) MUST throw a typed error carrying the row id. Never skip it silently.
- The publisher logs and does not rethrow on transport failure — a
cache-refresh push must not crash its host. The next tick retries because
the baseline advances only after
publishresolves, and the consumer can always recover the same slice over the pull path. - An ABSENT key and an EMPTY collection are different slices. The digest uses
the kernel's
canonicalJsonStringify, which drops anundefinedproperty and keeps[], so a producer that starts emitting an empty collection where it previously omitted the key republishes once. That is correct, and it costs nothing across a deploy because the boot publish re-seeds the baseline anyway.
