

13: Events and CI
DEFINE EVENT handles the transitions that field clauses cannot reach: rules across two fields on the same record, reactions to a status change, side effects on a different table. This chapter adds a few events to the planning schema.
The second half turns to something SurrealKit has been quietly setting up for you since init: tests. Once the catalog does real work (COMPUTED roll-ups, UNIQUE indexes, EVENT hooks), a pull request can break those behaviours without anyone noticing in CI. surrealkit test is how you catch that before merge. We will write one small suite after the events are in place.
Events vs field clauses
| Layer | Examples |
|---|---|
DEFAULT, VALUE, ASSERT, COMPUTED | Rules that only need one field, or the record it lives on |
DEFINE EVENT | "When X changes to Y, do Z" across the whole write |
Part 5 already used an order-paid style event. Planning schemas need the same kind of thing.
Keeping activity dates sane
Add database/schema/events_activity.surql:
DEFINE EVENT activity_end_after_start ON activity
WHEN $event IN ["CREATE", "UPDATE"]
AND $after.end < $after.start
THEN {
THROW "Activity end must be on or after start";
};You could try this as an ASSERT ($after.end > $this.start), and it would mostly work on update, but it gets awkward on create, before $this is fully wired up. An EVENT sees $before and $after for both CREATE and UPDATE, so it does not run into that problem.
Stopping an activity depending on itself (optional)
DEFINE EVENT no_self_dependency ON depends_on
WHEN $event = "CREATE" AND $after.in = $after.out
THEN {
THROW "An activity cannot depend on itself";
};This only catches the one-step case. Longer cycles (A depends on B, which depends on A) usually need application logic or a batch job to find. Treat EVENT hooks as cheap invariants on the hot path, not a general-purpose validator.
Logging when a milestone's activity finishes
When an activity's progress reaches completion, you might want to notify something downstream. In a teaching schema, an audit record is a reasonable stand-in:
DEFINE TABLE activity_audit SCHEMAFULL;
DEFINE FIELD activity ON activity_audit TYPE record<activity>;
DEFINE FIELD note ON activity_audit TYPE string;
DEFINE FIELD at ON activity_audit TYPE datetime DEFAULT time::now();
DEFINE EVENT log_activity_complete ON activity
WHEN $event = "UPDATE"
AND $before.progress < 1.0
AND $after.progress >= 1.0
THEN {
CREATE activity_audit SET
activity = $after.id,
note = "Activity marked complete";
};EVENT bodies run asynchronously relative to the statement that triggered them, so design for idempotency, and make failures visible, before you rely on this in production.
Keep events with the schema
DEFINE EVENT belongs next to the tables it touches, or in a shared database/schema/events.surql if you would rather group them. Either way, SurrealKit applies events on sync or rollout start like any other definition. Removing one is destructive, so that waits for rollout complete, once nothing depends on it any more.
Why SurrealKit has a test command
Schema in git is only half the story. You also want a machine to prove that the catalog still behaves the way the course (and your app) expect, such as:
Does
milestone.progressstill average the linked activities?Does a bad date still get rejected by the event you just added?
Did someone remove a
UNIQUEindex by accident?
surrealkit test answers those questions against a throwaway database: apply the schema, run suite files under database/tests/suites/, fail if a case breaks. It is the CI twin of local sync — fine to run often, never a substitute for rollouts on shared data.
A first test suite
Init already left database/tests/suites/smoke.toml. Add a second suite beside it at database/tests/suites/milestone_rollups.toml:
name = "milestone_rollups"
tags = ["computed"]
[[cases]]
name = "mean_progress_matches_activities"
kind = "schema_behavior"
actor = "root"
setup_sql = [
"CREATE project:test SET name = 'Test';",
"CREATE activity:a SET name = 'A', start = time::now(), end = time::now() + 1d, progress = 0.5;",
"CREATE activity:b SET name = 'B', start = time::now(), end = time::now() + 2d, progress = 1.0;",
]
action_sql = "CREATE milestone:m SET project = project:test, activities = [activity:a, activity:b], name = 'M1';"
verify_sql = "SELECT VALUE progress FROM ONLY milestone:m;"
expect_success = true
assertions = [
{ path = ".", equals = 0.75 },
]Run it with:
surrealkit test --user root --pass secret --ns main --db mainTest run: PASS (187ms)
Summary
- suites: 2 total, 0 failed
- cases : 2 total, 2 passed, 0 failedSurrealKit loads every *.toml under database/tests/suites/ (including the default smoke suite, which is the second suite in that count). test spins up an ephemeral database per suite, applies the schema, runs your seed files, runs the cases, and fails CI the moment an assertion breaks. Because it seeds, expect the schema-apply and seed output to repeat once per suite before the summary; --no-seed skips that step if a suite would rather start from an empty database. Good early targets:
COMPUTEDroll-ups, so a schema edit cannot quietly change the numbersUNIQUEindexes, to confirm they are actually thereEVENTdefinitions, to confirm they apply without error (and later, that they fire when you write the right data)Permissions and
DEFINE APIsurfaces, if you add either later
See testing with SurrealKit for the rest of the options.
A CI sketch
# .github/workflows/schema.yml (illustrative)
- run: surrealkit test
env:
SURREALDB_HOST: http://localhost:8000
SURREALDB_NAMESPACE: ci
SURREALDB_NAME: planningPair test with a review of the rollout plan on any pull request that touches database/schema/.
Checkpoint
DEFINE EVENTfor date validation, edge-case rules, and completion side effectssurrealkit testto guard against regressionsEvents and indexes versioned right alongside the tables
Next: capstone, aligning with the docs sample, rollout baseline, and running schema across environments.