navree
Documentation

Demo workspaces

Workspaces seeded from fixtures for sales and onboarding, kept read-only or reset every night, and marked with X-Navree-Demo for a Live/Demo switch.

On this page

A demo workspace shows your product with believable data: the companies, deals and reports a prospect expects to see, without anyone typing them in. Sales walks through it on a call, a new customer explores it before importing their own data, and your front end offers a Live/Demo switch between it and the user's real workspace.

In Navree a demo is an ordinary workspace of kind demo. Each module contributes a DemoSeeder that fills it from fixtures, and one setting decides what happens to writes: refused (read_only), or allowed and wiped every night (reset). Responses about a demo carry the X-Navree-Demo header, so a front end always knows where it is.

Setting up

Featureworkspace Demo workspaces are part of WorkspaceModule; there is nothing else to add. Three steps make a demo:

  1. Write a seeder per module

    Each module that has demo data implements DemoSeeder and registers it with m.demo_seeder::<CrmDemo>() (below).

  2. Add the fixtures

    JSON files under fixtures/demo (workspace.demo.fixtures), one per kind of record.

  3. Create the demo

    Shell

The workspace table gains one column for this, kind (live or demo, default live), added by the workspace migration set.

Writing a demo seeder

A seeder has a name, the tables it fills and a seed method. It receives a DemoSeed: the demo workspace, a unit of work already scoped to it, the time, and the fixtures.

src/crm/demo.rs

Register it next to the module's migrations:

src/crm/mod.rs

Seeders are providers, so they can inject services. They run in module dependency order, then in registration order, so a seeder can rely on the rows of the modules it imports. Names must be unique: two seeders named crm fail every seed with an error naming it.

What a seeder gets

DemoSeed::Returns
workspace(), id()The demo workspace and its id
uow()The unit of work to write in, scoped to the demo (see below); also the task's Uow::current()
now()The time of seeding, from the app's dyn Clock
fixture::<T>(name)<workspace.demo.fixtures>/<name>.json, placeholders resolved, deserialized
parse_fixture::<T>(json)The same for JSON held in the code (include_str!)
clear_table(table), clear::<E>()Deletes the demo's rows from a table; returns the count
isolation(), is_reset(), app()The isolation strategy, whether a clear came first, the app

The unit of work follows the isolation strategy. Under row isolation it is a transaction on the app's database that has entered the demo (navree.workspace is set on PostgreSQL, so row-level security admits the demo's rows), and you write with WorkspaceRepo. Under schema isolation its search_path starts with the demo's schema. Under database isolation it is a unit of work on the demo's own database.

Writes through WorkspaceRepo and Repo are audited like any other. To keep the audit log for real activity, insert through the transaction with plain SeaORM instead: model.insert(&*demo.uow().tx().await?).

Clearing before a reset

A reset clears every seeder's data, in the reverse of the seeding order, then seeds again. The default clear runs clear_table on each of tables(), in the order listed, so list child tables before their parents. clear_table deletes the rows whose workspace_id is the demo's under row isolation, and every row of the demo's own table under schema and database isolation (the delete names the demo's schema, so a shared table is never touched).

Override clear when a table names its workspace column differently or the data lives elsewhere. clear::<E>() uses the entity's WorkspaceScoped::WORKSPACE_COLUMN:

Rust

Fixtures

Fixtures are JSON files under workspace.demo.fixtures (default fixtures/demo, relative to the working directory). demo.fixture("crm/companies") reads fixtures/demo/crm/companies.json; names are relative paths without ...

fixtures/demo/crm/companies.json

A demo seeded with fixed dates looks abandoned a month later. A string that is exactly a placeholder is replaced with a time relative to demo.now() before the fixture is deserialized, so a demo reset tonight shows activity from yesterday:

PlaceholderBecomes
{{ now }}The seeding time, RFC 3339: 2026-10-27T09:00:00Z
{{ now - 2d }}, {{ now + 90m }}That time moved by minutes (m), hours (h), days (d) or weeks (w)
{{ today }}The seeding date: 2026-10-27
{{ today + 1w }}That date moved by days or weeks

Spaces inside the braces are optional. Any other string is kept as it is, Hello {{ name }} in a template body included, and a malformed now or today placeholder ({{ now + 3x }}) fails the seed with a message that shows it. Fixtures compiled into the binary work the same way: demo.parse_fixture(include_str!("../../fixtures/demo/crm/companies.json")).

Creating demo workspaces

From the command line, with the owner's id or email address (looked up in workspace.users_table):

Shell
Text

From code, DemoWorkspaces (exported by the module) creates and seeds in one call, in your unit of work:

Rust

The owner joins as owner, as with Workspaces::create. Under row and schema isolation the seeders write in uow, which stays scoped to the new demo afterwards: use it for this demo only. Under database isolation they write to the demo's database in a unit of work committed before create returns. A failing seeder fails the call; roll uow back and nothing is left. A regular seeder can call create to give every development database a demo.

DemoWorkspaces::Does
create(&uow, owner, NewWorkspace)Creates a demo workspace and seeds it
reset(id)Clears and seeds one demo again, in its own transaction; NotDemo (409, code not_a_demo_workspace) for a live workspace
reset_all()Resets every demo, by creation
all()Every demo workspace
seeders()The registered seeders' names, in order
mode(), options()workspace.demo.mode and the whole workspace.demo section

Workspace::kind() returns WorkspaceKind::Live or WorkspaceKind::Demo, and is_demo() answers the question directly.

Read-only demos

workspace.demo.mode = "read_only" is the default. A request to a demo workspace that would write (POST, PUT, PATCH or DELETE) is refused when the workspace is resolved, before the handler runs:

403 Forbidden

The error's code is demo_read_only (navree::workspace::DEMO_READ_ONLY), and the response carries X-Navree-Demo: read_only. The refusal applies to every route that resolves the workspace (the InWorkspace guard, CurrentWorkspace and WorkspaceUow), the module's own /api/workspaces/{workspace}/... routes included. Routes that don't name a workspace, such as signing in or editing the user's profile, are unaffected.

Two settings open the demo where it should be open:

config/app.toml
  • editor_roles lists the workspace roles that still write: the people who curate the demo.
  • allow lists the routes that write nothing lasting, or that a demo should accept anyway: a route name (RouteDef::name) or a method and the mounted path, as the route table shows it.

Endpoints that read with POST

Some protocols send reads as POST. GraphQL tells the workspace module which operation it runs: ctx.workspace() in a query or a subscription reads a read-only demo, in a mutation it is refused with the same 403 (the error's extensions.code is FORBIDDEN). For an endpoint of your own, put a DemoIntent in the request extensions before the workspace is resolved, from a layer or a guard:

Rust

DemoIntent::Write does the opposite, for a GET that changes data. gRPC calls are POSTs too, so a read-only demo treats each one as a write unless its request carries DemoIntent::Read.

Reset demos

With workspace.demo.mode = "reset", demo workspaces take writes like any other workspace, so a prospect can create a deal and move it through the pipeline. Every night the demos are cleared and seeded again, and the next visitor finds them as the fixtures describe.

config/app.toml

The nightly reset is the scheduled task workspace:demo-reset, which runs when the app has ScheduleModule and a schedule:work process. With WorkspaceModule::from_config() the task checks every minute whether the app's clock shows reset_at; with for_root it is scheduled daily at that time. Read-only demos are never reset on the schedule.

Run the command yourself to reset now, or to reset one demo:

Shell
Text

Each demo resets in one transaction: the seeders clear in reverse order, then seed in order, then the transaction commits. A failing seeder rolls that demo back and stops the command, so a demo is never left half empty. Naming a live workspace is an error.

Guests

By default a demo is for its members, like any workspace: add the sales team with Members::add. To open demos to every signed-in user, give guests a role:

config/app.toml

A signed-in user who is not a member then enters any demo workspace with that role (CurrentWorkspace::is_guest() is true, and member() describes a membership that is stored nowhere), and GET /api/workspaces lists the demos for them with that role. Live workspaces still answer 403 to non-members. owner can't be the guest role, and a read-only demo refuses a guest's writes as it refuses a member's.

Guests use your routes with the guest role's permissions. Of the module's own routes they get GET /api/workspaces/{workspace} only: the members, the invitations, the partitions and the workspace's settings answer 403 Only members of this workspace can do this. (code not_a_member), whatever the role grants. To decide exactly what guests may do in your modules, define a role for them:

config/app.toml

The Live/Demo switch

Every response about a demo workspace carries X-Navree-Demo, with the mode as its value; responses about live workspaces, and responses that name no workspace, don't have it:

HTTP

The header is in the CORS Access-Control-Expose-Headers list, so a front end on another origin reads it. Workspace resources carry the kind, so the switch knows which workspace to offer:

JSON

A front end keeps both in its state:

src/lib/workspace.ts

Show a banner while demoMode is set, disable the forms when it is read_only, and switch by sending the demo's slug in X-Workspace instead of the user's own. CurrentWorkspace::is_demo() and demo_mode() give the same answers on the server.

Configuration

config/app.toml

Environment variables follow the usual mapping: APP_WORKSPACE__DEMO__MODE=reset, APP_WORKSPACE__DEMO__GUEST_ROLE=viewer. editor_roles and allow also take a comma-separated string (APP_WORKSPACE__DEMO__EDITOR_ROLES=owner,admin). Invalid values fail the boot and name the key: a reset_at that isn't HH:MM, a role that isn't built in or defined under workspace.roles, owner as the guest role, an allow entry that is neither a route name nor METHOD /path.

Events

With EventsModule in the app, DemoWorkspaceSeeded (workspace.demo_seeded) is emitted once a demo's seeding commits, with workspace_id, slug, seeders (the names, in order) and reset (false when the demo was created, true after a reset). Creating a demo also emits WorkspaceCreated and MemberAdded, as creating any workspace does.

Testing

Point workspace.demo.fixtures at a temporary directory, create the demo through the command or DemoWorkspaces, and assert on the header and the refusal:

tests/demo.rs

To test the nightly reset, override dyn Clock with a FixedClock set to reset_at and call Scheduler::tick with the same time.