-
Notifications
You must be signed in to change notification settings - Fork 89
docs: Added documentation for new reactive future call functionality. #447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,131 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Reactive Future Calls | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Reactive future calls let you react to database changes in near real-time. When a row is inserted, updated, or deleted, Serverpod can automatically and asynchronously invoke your code with the changed data. This is useful for scenarios like sending notifications when an order is confirmed, syncing data to external systems, or triggering workflows based on state changes. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Under the hood, reactive future calls use PostgreSQL triggers and an outbox pattern. A database trigger fires within the same transaction as the data change, writing an entry to an outbox table. Serverpod periodically polls the outbox on a configurable scan interval and dispatches matching entries to your handler. Because the trigger runs in the same transaction, rolled-back changes never produce events. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The users don't need to know how things work under the hood. What they need to know here is that the rolled-back changes never produce events. We can skip directly to the point.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Creating a reactive future call | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Every model with a database table gets a generated intermediate class that you extend. For example, if you have a `Trip` model, Serverpod generates `TripReactiveFutureCall`: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```dart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import 'package:serverpod/serverpod.dart'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import 'src/generated/protocol.dart'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class NotifyPassengersAboutTripConfirmation extends TripReactiveFutureCall { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WhereExpressionBuilder<TripTable> get where => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (t) => t.status.equals('Confirmed'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Future<void> react(Session session, List<Trip> objects) async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (final trip in objects) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Send notifications to passengers | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The `where` getter defines a condition that becomes a `WHEN` clause on the PostgreSQL trigger. Only changes matching this condition will create outbox entries. In the example above, the trigger only fires when the `status` column equals `'Confirmed'`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mentioning 'outbox' is an implementation details of no concern to our users. Could we boil these two paragraphs down to the essentials? Perhaps something like: The |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The `react` method is called with all matching rows from a single outbox scan. Rows are batched together for efficiency. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Detecting column changes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Standard expressions filter on the new row's values, but sometimes you need to react when a specific column *changes*. The `hasChanged()` method generates a trigger condition that compares the old and new values of a column: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```dart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class OnSensorHeightChanged extends SensorReactiveFutureCall { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WhereExpressionBuilder<SensorTable> get where => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (t) => t.sensorHeight.hasChanged(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Future<void> react(Session session, List<Sensor> objects) async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Handle sensor height changes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| This produces the PostgreSQL trigger condition `OLD."sensorHeight" IS DISTINCT FROM NEW."sensorHeight"`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| :::info | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| When `hasChanged()` is used in the condition, the trigger is restricted to `UPDATE` operations only. This is because `OLD` row values are not available for `INSERT` triggers, and `NEW` values are not available for `DELETE` triggers. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ::: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| You can compose `hasChanged()` with other expressions using `&` (AND) and `|` (OR): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```dart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class OnCriticalSensorChange extends SensorReactiveFutureCall { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WhereExpressionBuilder<SensorTable> get where => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (t) => t.sensorHeight.hasChanged() & | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (t.sensorTemperature.hasChanged() | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t.sensorTemperature > 100.0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Future<void> react(Session session, List<Sensor> objects) async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // React when height changes AND (temperature changes OR exceeds 100) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Code generation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| After creating your reactive future call class, run code generation: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| $ serverpod generate | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We will update this to match the new serverpod start workflow in a different PR. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Your reactive future calls are automatically discovered and registered alongside regular future calls. No manual registration is needed. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## How it works | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are many technical details here that the user probably does not need to concern themselves with. Consider just saying something like for the whole ## How it works section: How it worksReactive future calls use a scan interval. The same interval as regular future calls, configured via Rows that accumulate between scans are batched together and delivered to |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| When the server starts, the following happens automatically: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 1. PostgreSQL triggers are created (or replaced) for each registered reactive future call. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 2. Orphaned triggers from previously registered handlers are dropped. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 3. An outbox scanner starts polling for new entries. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| When data changes in a watched table: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 1. The trigger evaluates the `WHEN` condition. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 2. If matched, the trigger inserts a row into the outbox table within the same transaction. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 3. The outbox scanner picks up new entries on its next poll. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 4. Entries are grouped by handler and dispatched to the `react` method. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 5. Processed entries are deleted from the outbox. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| :::info | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: this names
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ::: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Transaction safety | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: this section talks about transaction commit/rollback semantics without linking to the transactions docs. A reader unfamiliar with
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - If a transaction is rolled back, the outbox entry is also rolled back and `react` is never called. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - If a transaction is committed, the outbox entry is guaranteed to exist and will be processed. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```dart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This will NOT trigger react — the transaction is rolled back | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await session.db.transaction((transaction) async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw Exception('Something went wrong'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This WILL trigger react — the transaction is committed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await session.db.transaction((transaction) async { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+113
to
+119
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| await Trip.db.insertRow( | |
| session, | |
| Trip(status: 'Confirmed'), | |
| transaction: transaction, | |
| ); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow( | |
| session, | |
| Trip(status: 'Confirmed'), | |
| transaction: transaction, | |
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if we really need this change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When fact-checking this, I find the suggestion by Copilot to be correct.
Wrapping operations in session.db.transaction((transaction) async { ... }) doesn't automatically enlist them in the transaction.
You have to explicitly opt each database call into the transaction by passing transaction: transaction. Without it, the call runs on its own separate connection and commits immediately. The outer transaction has no hold over it.
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue in the committed-transaction example: Trip.db.insertRow(...) should receive transaction: transaction to ensure the insert is performed within the transaction being committed (consistent with the Transactions docs) and to make the example behavior correct.
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need this change either
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here, we should apply it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: em-dashes in code comments. The style guide discourages them in running text, and comments read as prose to the reader. Swap for periods or colons.
| // This will NOT trigger react — the transaction is rolled back | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| }); | |
| // This will NOT trigger react. The transaction is rolled back. | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react. The transaction is committed. | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have started adding frontmatter description to all pages for SEO.
Also, use sentence case for headings.