Skip to content

feat(outbox): add OpCursor for pre-commit reads of op-buffered events - #79

Merged
bodymindarts merged 2 commits into
mainfrom
feat/outbox-op-cursor
Jul 22, 2026
Merged

feat(outbox): add OpCursor for pre-commit reads of op-buffered events#79
bodymindarts merged 2 commits into
mainfrom
feat/outbox-op-cursor

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Jul 21, 2026

Copy link
Copy Markdown
Member

What

Adds OpCursor — a passive position token into an outbox's op-local publish buffer — plus reads on Outbox to consume events published within an es_entity op before commit:

API
Outbox::cursor(&op) -> Result<OpCursor<P, Tables>, CursorError> mark current publish position (errors on a hookless op)
Outbox::new_events(&op, &mut cursor) -> &[P] advancing read (borrowed)
Outbox::map_new(&op, &mut cursor, f) -> Vec<T> advancing filter_map
Outbox::peek_new(&op, &cursor) -> &[P] non-advancing read

Also bumps es-entity → 0.11.3 and job → 0.6.29.

Why

Publishing to an outbox within an op buffers events on a (merged) commit hook — nothing is written until op.commit(). OpCursor lets you read those buffered events back before commit. The motivating use case is atomically republishing a mapped projection of one outbox's events onto another outbox in the same transaction (e.g. cala → lana), with per-use-case mapping inline at the call site.

The cursor is a passive token (index + PhantomData) passed to reads on the Outbox, exactly as EventSequence is passed to listen_persisted. Reads borrow the op, so the buffer can't change while a slice is held; the type parameters pin a cursor to its outbox's payload/table types.

Fail loudly on hookless ops

cursor() returns Result and errors with CursorError::HooksUnsupported when the op does not support commit hooks (a bare sqlx::Transaction, which persists publishes immediately with no op-local buffer) — rather than handing back a cursor that would silently yield nothing (which, in the repost use case, would persist source events while silently dropping the mapped repost).

Detection uses es-entity 0.11.3's new &self AtomicOperation::supports_hooks() probe, so cursor() stays borrow-light (&op, no &mut) and the reads stay infallible: a valid OpCursor implies hook support, and commit_hook()==None still covers the legitimate "supported but nothing published yet" case.

Tests

tests/op_cursor.rs — 6 integration tests, all green:

  • cursor sees only events published after creation (+ advance + stream continuation)
  • peek does not advance
  • cursor scoped to its own outbox (distinct payload type)
  • mapped republish commits atomically (5 rows across 2 outboxes, one tx)
  • mapped republish rolls back atomically
  • cursor() errors on an op without hook support (bare-tx)

Full suite: 4 inbox + 6 op_cursor + 12 outbox + 4 handler + 1 doctest passing on es-entity 0.11.3 / job 0.6.29; cargo fmt --check and cargo clippy --all-features --all-targets clean.

🤖 Generated with Claude Code


Note

Medium Risk
Touches transactional outbox publish/commit-hook behavior and adds a new public API path for multi-outbox republish; mistakes could drop mapped events or change atomicity expectations, though behavior is covered by integration tests.

Overview
Adds OpCursor and Outbox APIs to read events buffered on a transaction’s commit hook before commit()—so callers can see only publishes that happen after a marked position (e.g. after a downstream ledger call on the same es_entity op).

Outbox::cursor, new_events, peek_new, and map_new expose advancing vs non-advancing reads and a filter_map helper for the main use case: map source outbox events and republish onto another outbox in the same transaction. Reads borrow the op so the pending slice stays stable while held.

cursor() returns CursorError::HooksUnsupported when the op has no commit hooks (e.g. bare sqlx::Transaction), using es-entity 0.11.3’s supports_hooks(), instead of a cursor that would silently return nothing. PersistEvents::pending() backs these reads from the hook’s pre-commit buffer.

Public re-exports OpCursor / CursorError from the crate root; tests/op_cursor.rs covers scoping, peek vs consume, atomic commit/rollback republish, and hookless ops.

Reviewed by Cursor Bugbot for commit 0f43b4c. Bugbot is set up for automated code reviews on this repo. Configure here.

@bodymindarts
bodymindarts force-pushed the feat/outbox-op-cursor branch from 47941e9 to 8e6efe2 Compare July 21, 2026 18:56

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 88ef3b6. Configure here.

Comment thread src/out/mod.rs
bodymindarts and others added 2 commits July 22, 2026 09:28
For AtomicOperation::supports_hooks() (GaloyMoney/es-entity#155), used by
OpCursor to detect commit-hook support. Keeps job at 0.6.30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Publishing to an outbox within an es_entity op buffers events on a
(merged) commit hook; nothing is written until op.commit(). OpCursor
marks a position in that buffer so events published after it can be read
back before commit, via reads on the Outbox that mirror how
EventSequence is passed to listen_persisted:

- Outbox::cursor(&op)                  -> Result<OpCursor<P, Tables>, CursorError>
- Outbox::new_events(&op, &mut cursor) -> &[P]   (advancing)
- Outbox::map_new(&op, &mut cursor, f) -> Vec<T> (advancing, filter_map)
- Outbox::peek_new(&op, &cursor)       -> &[P]   (non-advancing)

The cursor is a passive token (index + PhantomData); reads borrow the op,
so the buffer can't change while a slice is held. The type parameters pin
a cursor to its outbox's payload/table types.

cursor() fails loudly with CursorError::HooksUnsupported on an op that
does not support commit hooks (a bare sqlx::Transaction, which persists
publishes immediately with no op-local buffer) rather than returning a
cursor that silently yields nothing. Support is detected via es-entity's
&self AtomicOperation::supports_hooks(), so the signature stays
borrow-light (&op) and the reads stay infallible.

Motivating use case: atomically republish a mapped projection of one
outbox's events onto another outbox in the same transaction (e.g.
cala -> lana). PersistEvents exposes a crate-private pending() accessor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bodymindarts
bodymindarts force-pushed the feat/outbox-op-cursor branch from 5b012d0 to 0f43b4c Compare July 22, 2026 07:28
@bodymindarts
bodymindarts merged commit 8c50e16 into main Jul 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant