From 6bce6825db624f6535f3ed0d6f30122fae8d4458 Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Tue, 13 Oct 2026 14:39:44 -0300 Subject: [PATCH 1/5] docs: Document the generated watch method --- .../02-database/02-crud.md | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md b/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md index 56f984b7..78679921 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md @@ -62,7 +62,7 @@ When using `ignoreConflicts` with models that have [non-persistent fields](./tab ## Read -There are three different read operations available. +There are four different read operations available. ### Finding by id @@ -107,6 +107,30 @@ This operation returns a `List` of your models matching the filtering criteria. See [filter](./filtering) and [sort](./sorting) for all filter and sorting operations and [pagination](pagination) for how to paginate the result. +### Watching rows + +To keep a result up to date, use the `watch` method. It takes the same `where`, `orderBy`, `orderByList`, `limit`, `offset`, and `include` parameters as `find`, and returns a `Stream` that emits the matching rows as soon as you listen to it. + +```dart +var subscription = Company.db.watch( + session, + where: (t) => t.name.like('%Ltd'), + orderBy: (t) => t.name, +).listen((companies) { + // Handle the latest matching rows. +}); +``` + +The stream runs the query again and emits the result every time a write to one of the watched tables commits, until you cancel the subscription. The watched tables are the model's table and every table that `where`, `orderBy`, `orderByList`, and `include` refer to. Writes inside a [transaction](transactions) emit once it commits, and a rolled-back transaction emits nothing. + +To also react to writes on other tables, pass them with `alsoTriggerOnTables`, for example `alsoTriggerOnTables: [Town.t]`. This is needed when a filter reads a table only through raw SQL, which `watch` does not inspect. + +By default, the query runs at most once every 30 milliseconds, no matter how many writes happen in between. Pass a different `Duration` as `throttle` to change the interval, or `null` to run the query after every write. + +:::note +The `watch` method is only supported on SQLite, which includes the [client-side database](client-side-database). On PostgreSQL, calling it throws an `UnsupportedError`. +::: + ## Update There are multiple update operations available for different use cases. From b9addf4bcbdcdd77f3540bd03563098b3b1d3a91 Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Tue, 13 Oct 2026 14:40:18 -0300 Subject: [PATCH 2/5] docs: Document unsafeWatch for raw SQL queries --- .../02-database/12-raw-access.md | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md b/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md index e794cfd8..53e91907 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md @@ -28,6 +28,27 @@ int result = await session.db.unsafeExecute( ); ``` +## `unsafeWatch` + +Returns a `Stream` that runs a read query and emits its `DatabaseResult` as soon as you listen to it, then again every time a write to one of the queried tables commits. It is the raw counterpart of the generated [`watch`](crud#watching-rows) method and takes the same `throttle` parameter. + +```dart +var subscription = session.db.unsafeWatch( + r'SELECT id, size FROM apparel WHERE color = @color', + parameters: QueryParameters.named({'color': 'green'}), +).listen((result) { + // Handle the latest rows. +}); +``` + +The queried tables are detected automatically. To set them yourself, pass their names with `triggerOnTables`. The list replaces the detection, so include every table the stream should react to. + +The method is only supported on SQLite. On PostgreSQL, calling it throws an `UnsupportedError`. + +:::warning +SQLite may not report a `DELETE` without a `WHERE` clause, so streams from `unsafeWatch` and the generated `watch` method don't emit after it. Add a `WHERE` clause, such as `WHERE true`, when clearing a watched table. +::: + ## `unsafeSimpleQuery` Similar to `unsafeQuery`, but uses the simple query protocol. This protocol does not support parameter binding, making it more susceptible to SQL injection. **Use with extreme caution and only when absolutely necessary.** @@ -84,7 +105,7 @@ var result = await session.db.unsafeQuery( ``` :::danger -Always sanitize your input when using raw query methods. For the `unsafeQuery` and `unsafeExecute` methods, use query parameters to prevent SQL injection. Avoid using `unsafeSimpleQuery` and `unsafeSimpleExecute` unless the simple query protocol is strictly required. +Always sanitize your input when using raw query methods. For the `unsafeQuery`, `unsafeExecute`, and `unsafeWatch` methods, use query parameters to prevent SQL injection. Avoid using `unsafeSimpleQuery` and `unsafeSimpleExecute` unless the simple query protocol is strictly required. ::: ## Working with results From ccea4b4bad0dc3be32221a75da2aa241cf3980e4 Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Tue, 13 Oct 2026 14:40:45 -0300 Subject: [PATCH 3/5] docs: Show how to rebuild widgets from a client database watch --- .../02-database/16-client-side-database.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/16-client-side-database.md b/docs/06-concepts/03-data-and-the-database/02-database/16-client-side-database.md index 119d63bc..a0f59e1d 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/16-client-side-database.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/16-client-side-database.md @@ -95,6 +95,35 @@ var companies = await Company.db.find( - [Vector](vector-and-geography-fields#vector-fields) queries are not supported, and [geography](vector-and-geography-fields#geography-fields) values round-trip through CRUD but throw on spatial query operations. - Column types and encodings differ from Postgres. See [Field types](../models/field-types#postgres-vs-sqlite). +## Rebuild widgets when data changes + +To keep a widget in sync with the database, [watch](crud#watching-rows) a query and pass the stream to a `StreamBuilder`. The builder runs with the current rows, then again after every committed write to the watched tables. This example assumes the widget receives the session as `session`: + +```dart +class _CompanyListState extends State { + // Create the stream once, so rebuilds don't restart the query. + late final _companies = Company.db.watch( + widget.session, + orderBy: (t) => t.name, + ); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _companies, + builder: (context, snapshot) { + final companies = snapshot.data ?? []; + return ListView( + children: [for (final company in companies) Text(company.name)], + ); + }, + ); + } +} +``` + +The `StreamBuilder` cancels its subscription when the widget is removed, which stops the watch. + ## Sync tables with the server :::warning From a9615f8c897936a05507be981d2b36c7dfe884ca Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Tue, 13 Oct 2026 14:41:21 -0300 Subject: [PATCH 4/5] docs: Note that watching queries requires disabling test rollback --- docs/06-concepts/08-testing/02-writing-tests.md | 2 +- docs/06-concepts/08-testing/03-configuration.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/06-concepts/08-testing/02-writing-tests.md b/docs/06-concepts/08-testing/02-writing-tests.md index 01124fdc..820bcaf7 100644 --- a/docs/06-concepts/08-testing/02-writing-tests.md +++ b/docs/06-concepts/08-testing/02-writing-tests.md @@ -129,7 +129,7 @@ The generated file exports these, so you can name them in a test without importi | `ServerpodUnauthenticatedException` | An endpoint call was made without authentication and the endpoint requires it. | | `ServerpodInsufficientAccessException` | The authentication used did not carry the scopes the endpoint requires. | | `ConnectionClosedException` | A stream connection closed with an error, for example because authentication was revoked. | -| `InvalidConfigurationException` | The endpoint ran database calls or transactions in parallel, which the automatic rollback cannot wrap. See [`rollbackDatabase`](configuration#rollbackdatabase). | +| `InvalidConfigurationException` | Database calls or transactions ran in parallel, or a query was watched, neither of which the automatic rollback can wrap. See [`rollbackDatabase`](configuration#rollbackdatabase). | The test server also throws when it fails to start or takes longer than the start timeout. That error type is not exported, so you cannot catch it by name, but the message says which of the two happened. diff --git a/docs/06-concepts/08-testing/03-configuration.md b/docs/06-concepts/08-testing/03-configuration.md index 5120ae8d..3985df4f 100644 --- a/docs/06-concepts/08-testing/03-configuration.md +++ b/docs/06-concepts/08-testing/03-configuration.md @@ -68,7 +68,7 @@ By default each test runs inside a transaction that is rolled back when that tes | `RollbackDatabase.afterAll` | Once the whole `withServerpod` group finishes. | | `RollbackDatabase.disabled` | Never. Data stays until the group finishes and its database is dropped. | -Three situations call for changing it. +Four situations call for changing it. **Tests that build on each other.** When one test depends on what the previous one left behind, `afterAll` keeps state across the group and still rolls everything back at the end. Tests that share state are usually worth avoiding, but this helps when the setup is expensive. @@ -85,6 +85,8 @@ Future concurrentTransactionCalls(Session session) async { } ``` +**Watching queries.** The [`watch`](../data-and-the-database/database/crud#watching-rows) and [`unsafeWatch`](../data-and-the-database/database/raw-access#unsafewatch) streams only see committed data, so they cannot observe writes inside the rollback transaction. Both throw `InvalidConfigurationException` unless you pass `RollbackDatabase.disabled`. + With rollback disabled, later tests in the same group see what earlier tests committed. To start each test from a clean state, clean up in `tearDown`: ```dart From acd08976fc68dfec9d9f1117e0f1d48a8ca699f7 Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Mon, 26 Oct 2026 21:04:51 -0300 Subject: [PATCH 5/5] docs: Scope the full-table DELETE watch warning to raw SQL --- .../03-data-and-the-database/02-database/12-raw-access.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md b/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md index 53e91907..7b6291fb 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/12-raw-access.md @@ -46,7 +46,7 @@ The queried tables are detected automatically. To set them yourself, pass their The method is only supported on SQLite. On PostgreSQL, calling it throws an `UnsupportedError`. :::warning -SQLite may not report a `DELETE` without a `WHERE` clause, so streams from `unsafeWatch` and the generated `watch` method don't emit after it. Add a `WHERE` clause, such as `WHERE true`, when clearing a watched table. +A raw `DELETE` without a `WHERE` clause can skip SQLite's change notifications, so streams from `unsafeWatch` and the generated `watch` method don't emit after it. Add a `WHERE` clause, such as `WHERE true`, when clearing a watched table. The generated delete methods always include one. ::: ## `unsafeSimpleQuery`