Skip to content
Menu

Documentation

Add an event

Goal: a new typed event that your module publishes, such as notes.note.archived.v1, so other modules can react to it.

Use it when something happened in your module that other modules may care about, and the producer does not need their answer.

Do not use it when you need an answer now (use a contract), or to react to another module's event (that is a consumer: a view of the other module's channel, never a second producer). To change the payload of an event you already publish, see Change an event's payload.

Prerequisites

  • The module exists (Add a module).
  • If events.py already exists, it must parse and import Event from sandloom.sdk (and UUID from uuid for the default fields). The generator refuses otherwise and names the missing import.

Steps

1. Run the generator

sandloom add event notes NoteArchived
sandloom add event notes NoteArchived --field note_id:UUID --field reason:str   # explicit fields
  • The channel is derived from the module and the class name: NoteArchived in notes gives notes.note.archived.v1.
  • Without --field, the event gets <first word>_id: UUID (here note_id). A tenant_id: UUID field is always added; a consumer binds the tenant from it.
  • --field name:type takes str, int, float, bool, UUID, datetime, date or Decimal.
  • The class is appended to events.py (created when absent). Existing code is never rewritten.

2. Read what it wrote

The appended class (the rest of the file is the module's existing events):

class NoteArchived(Event, channel="notes.note.archived.v1"):
    note_id: UUID
    tenant_id: UUID

The channel's first segment is the module's name, so this class is the channel's producer: sandloom generate (run last by sandloom add) snapshots its schema and exports it to the shared DTO package (<app>/shared/events/notes.note.archived.v1.json), where consumers in other repositories verify their views against it.

3. Publish it

Publish from a service, inside the unit of work that makes the change. The event is written to the transactional outbox and is delivered only if the unit commits. The tutorial app's tasks module does it like this:

async def create_item(body: ItemIn, *, owner_id: UUID) -> ItemOut:
    item = TasksItem(title=body.title, owner_id=owner_id)
    db().add(item)
    await db().flush()

.publish() outside a unit of work raises (SANDLOOM-R002). In a route, the request's unit is already open.

4. Test it

published(E) returns the events of E's channel that the code under test published:

async def test_create_and_read_an_item(client: Any) -> None:
    _, headers = as_user(tenant_id=uuid4())
    with fake_contract(STATS_QUOTA_V1, Quota(allow=True)):
        created = await client.post(
            "/api/v1/tasks/items", json={"title": "first"}, headers=headers
        )
    assert created.status_code == 201, created.text
    assert len(published(ItemCreated)) == 1
    item = created.json()
    fetched = await client.get(f"/api/v1/tasks/items/{item['id']}", headers=headers)
    assert fetched.status_code == 200
    assert fetched.json()["title"] == "first"


async def test_the_quota_refuses_a_new_item(client: Any) -> None:
    _, headers = as_user(tenant_id=uuid4())
    with fake_contract(STATS_QUOTA_V1, Quota(allow=False)):
        response = await client.post(
            "/api/v1/tasks/items", json={"title": "x"}, headers=headers
        )
    assert response.status_code == 402
    assert published(ItemCreated) == []


async def test_items_need_an_authenticated_user(client: Any) -> None:
    response = await client.post("/api/v1/tasks/items", json={"title": "x"})
    assert response.status_code == 401

Verify

sandloom test notes

The first run writes the producer's schema to module.snapshot.json and to the shared DTO package. Commit both. From then on, only an added optional field is accepted on the same .v1; anything else fails with SANDLOOM-T005.

Errors you may hit

  • SANDLOOM-B015: the channel is not <module>.<entity>.<verb>.vN, a field is not a wire type, two producer classes share a channel, or the producer lives outside its module.
  • SANDLOOM-R002: .publish() ran outside a unit of work.
  • SANDLOOM-R004: the payload does not match the channel's schema.
  • SANDLOOM-T005: a breaking change on a channel that already has a committed schema; publish a .v2.