Summary
Add an opt-in, per-entity emitEvents capability to .apsorc that makes the generator emit a transactional outbox + a TypeORM EntitySubscriber (and a relay scaffold), so every create/update/delete on that entity writes a durable event in the same DB transaction as the state change — a generated "no silent writes" guarantee.
Motivation
"Every state change emits an event, and the event stream is complete" is a cross-cutting invariant for event-driven / payments / audit systems. Doing it correctly requires the transactional outbox pattern (the dual-write problem: a naive post-commit publish can be lost if the process dies between commit and publish — unacceptable for a ledger).
Today each service hand-rolls this: an outbox table, a TypeORM subscriber or a NestJS interceptor, and a relay. We've built exactly that by hand in a NestJS + TypeORM service and will need the same spine in several more services in the same codebase. It's generic plumbing that belongs in the generator, the same way CRUD/DTOs/idempotency do.
Critically, the durability lever is same-transaction emission, which a generated EntitySubscriber can do cleanly (its afterInsert/afterUpdate/afterRemove hooks receive event.manager — the active transaction's manager) but hand-written HTTP interceptors cannot (they fire post-commit at the request boundary).
Proposed generation
When an entity opts in (emitEvents: true), generate (into autogen/, so it regenerates):
- An
OutboxEvent table (once per service): id, type (e.g. entity.action), payload (json), status (pending/published/failed), attempts, created_at, publishedAt (nullable timestamptz).
- A generic
EntitySubscriber scoped to the opted-in entities. In afterInsert/afterUpdate/afterRemove it writes an OutboxEvent row via event.manager (same transaction), deriving type from the entity name + operation and the payload from the entity. It must skip its own bookkeeping tables (the outbox table, etc.) to avoid recursion.
- A relay stub that publishes pending rows (left as an extension point — delivery/signing/retry is app-specific).
Keep app semantics out of the generator (extension points)
The mechanism is generic; the contract is not. Please don't hardcode event naming/payload shape. Expose hooks so the app maps the generic CRUD event to its own contract, e.g.:
- a configurable type format (default
entity.action; let the app override the taxonomy),
- a payload transform hook (e.g. to serialize to a public/Stripe-shaped object with prefixed ids),
- the relay/delivery left to the app.
In our case the generated outbox + subscriber would be the durable spine, and our extension layer would supply the Stripe-shaped naming/serialization and the webhook delivery.
Design considerations / open questions
- Granularity: per-entity flag vs a global
emitEvents default with per-entity opt-out.
- Recursion guard: the subscriber must ignore the outbox table and any idempotency/bookkeeping tables.
- Dedup: events should carry a stable id so consumers can dedupe (at-least-once delivery).
- Non-entity-manager writes: raw
QueryBuilder deletes/updates bypass subscribers — document that, or also offer the interceptor variant for request-context enrichment.
- Multi-DB / multiple DataSources: subscriber registration per DataSource.
Prior art / why us
This mirrors the standard transactional-outbox pattern (Debezium/outbox, microservice patterns). We'd happily migrate our hand-rolled NestJS+TypeORM spine (interceptor + outbox + relay) onto a generated capability and dogfood it.
Environment
@apso/cli/0.16.1
- language: typescript (NestJS + TypeORM)
Summary
Add an opt-in, per-entity
emitEventscapability to.apsorcthat makes the generator emit a transactional outbox + a TypeORMEntitySubscriber(and a relay scaffold), so every create/update/delete on that entity writes a durable event in the same DB transaction as the state change — a generated "no silent writes" guarantee.{ "name": "Product", "emitEvents": true, // <-- opt in (per entity; or a global default) "fields": [ ... ] }Motivation
"Every state change emits an event, and the event stream is complete" is a cross-cutting invariant for event-driven / payments / audit systems. Doing it correctly requires the transactional outbox pattern (the dual-write problem: a naive post-commit publish can be lost if the process dies between commit and publish — unacceptable for a ledger).
Today each service hand-rolls this: an outbox table, a TypeORM subscriber or a NestJS interceptor, and a relay. We've built exactly that by hand in a NestJS + TypeORM service and will need the same spine in several more services in the same codebase. It's generic plumbing that belongs in the generator, the same way CRUD/DTOs/idempotency do.
Critically, the durability lever is same-transaction emission, which a generated
EntitySubscribercan do cleanly (itsafterInsert/afterUpdate/afterRemovehooks receiveevent.manager— the active transaction's manager) but hand-written HTTP interceptors cannot (they fire post-commit at the request boundary).Proposed generation
When an entity opts in (
emitEvents: true), generate (intoautogen/, so it regenerates):OutboxEventtable (once per service):id,type(e.g.entity.action),payload(json),status(pending/published/failed),attempts,created_at,publishedAt(nullable timestamptz).EntitySubscriberscoped to the opted-in entities. InafterInsert/afterUpdate/afterRemoveit writes anOutboxEventrow viaevent.manager(same transaction), derivingtypefrom the entity name + operation and the payload from the entity. It must skip its own bookkeeping tables (the outbox table, etc.) to avoid recursion.Keep app semantics out of the generator (extension points)
The mechanism is generic; the contract is not. Please don't hardcode event naming/payload shape. Expose hooks so the app maps the generic CRUD event to its own contract, e.g.:
entity.action; let the app override the taxonomy),In our case the generated outbox + subscriber would be the durable spine, and our extension layer would supply the Stripe-shaped naming/serialization and the webhook delivery.
Design considerations / open questions
emitEventsdefault with per-entity opt-out.QueryBuilderdeletes/updates bypass subscribers — document that, or also offer the interceptor variant for request-context enrichment.Prior art / why us
This mirrors the standard transactional-outbox pattern (Debezium/outbox, microservice patterns). We'd happily migrate our hand-rolled NestJS+TypeORM spine (interceptor + outbox + relay) onto a generated capability and dogfood it.
Environment
@apso/cli/0.16.1