navree
Documentation

Shared libraries

Templates, courses and other content kept for the whole app, an organization or one workspace, read together by each workspace and cloned to be changed.

On this page

Some content belongs to more than one workspace. Your product ships message templates, courses, badges and policy documents that every customer starts from. A company that runs several workspaces adds its own, for all of them. Each workspace keeps the ones it wrote or adapted. A shared library holds the three levels in one table: a workspace reads the global items, its organization's and its own as one list, changes only its own, and clones a shared item when it wants to change it.

How it works

Every row of a library table has one owner, written in a scope column and the id that goes with it:

scopeorganization_idworkspace_idSeen by
globalNULLNULLevery workspace and organization
organizationthe organizationNULLthe organization and its workspaces
workspaceNULLthe workspacethat workspace

A CHECK constraint keeps the three columns consistent, so no row is global and a workspace's at once. Inside a workspace request:

  • queries see the global items, the workspace's organization's and the workspace's own;
  • a new item joins the workspace's library;
  • changing or deleting a global or an organization's item is refused with a 403, code library_read_only;
  • cloning an item gives the workspace its own copy, which records the original in cloned_from_id.

Global items come from seeders and the admin panel, organization items from the organization's admins. The organization level needs organizations turned on; without them a library has global and workspace items.

Making a table a library

Featureworkspace Shared libraries are part of WorkspaceModule, in navree::workspace::library. A library takes a migration, a model and one registration.

The migration

LibraryColumns adds the four columns, an index on each and the CHECK constraint to a table created in a schema-builder migration:

src/templates/migrations.rs
ColumnTypeIndex
scopestring of up to 16 characterstemplates_scope_index
organization_idnullable UUIDtemplates_organization_id_index
workspace_idnullable UUIDtemplates_workspace_id_index
cloned_from_idnullable UUID (big integer with .integer_keys())templates_cloned_from_id_index

UUIDs are written the way navree-orm stores them on each database: native uuid on PostgreSQL, BINARY(16) on MySQL and MariaDB. Use library.uuid(t, name) for the table's other UUID columns too, its key included. The constraint is named <table>_library_owner_check:

PostgreSQL

SQLite writes it into CREATE TABLE, MySQL enforces it from 8.0.16. For a model keyed by integers, LibraryColumns::new(dialect).integer_keys() makes cloned_from_id a big integer. A model that names its columns differently passes itself: LibraryColumns::of::<Template>(dialect) reads the names from its Library constants. The constraint is an ordinary check constraint of the schema builder.

The model

A library model is a navree-orm model with the four columns, implementing Library:

src/templates/model.rs

LibraryScope is Global, Organization or Workspace, stored as global, organization and workspace without a cast, and serialized the same way. It defaults to Workspace, so a new item made inside a workspace request belongs to that workspace. The model isn't #[model(workspace)]: a library's rows aren't all one workspace's, and the library's own scope takes that role. Columns under other names are set with the trait's constants (SCOPE_COLUMN, ORGANIZATION_COLUMN, WORKSPACE_COLUMN, CLONED_FROM_COLUMN).

Registering it

Register the migrations and the model in the module that owns the table:

src/templates/mod.rs

m.library::<Template>() comes with ModuleBuilderWorkspaceExt (in the prelude). It:

  • installs LibraryVisibility, the global scope that keeps the model's queries to what the current workspace sees;
  • adds LibraryGuard, the model hook that keeps a workspace's writes to its own items;
  • registers the model with navree-orm, so model:show lists it and its guard;
  • classifies the table as a library for navree check, so a SeaORM entity on it needs no workspace_entity or global_entity.

Reading a library

In a workspace request the model's queries see the workspace's library without being asked:

src/templates/controller.rs

The current workspace is navree-orm's: the one the request's unit of work entered (CurrentWorkspace and InWorkspace enter it), or the one a task runs with inside navree::orm::workspace::scope(id, ..). The workspace's organization is read in the same statement. Where no workspace is current (a command, a seeder, an organization request, the admin panel), the model's queries see the global items only. A query can leave the scope out on purpose with .without_global_scope::<LibraryVisibility>().

To read a given viewer's library, or one owner's items, LibraryRepo builds the query. Its queries replace the visibility scope, so they read the same inside and outside a workspace request:

Rust

A viewer is a LibraryViewer. &CurrentWorkspace and &Workspace give the workspace with its organization, &CurrentOrganization the organization; LibraryViewer::global(), LibraryViewer::organization(id) and LibraryViewer::workspace(id).in_organization(Some(org)) build one from ids. viewer.sees(&owner) answers for one owner.

Writing items

In a workspace

Inside a workspace request, writes keep to the workspace's library. A new item without an owner joins it:

Rust

Saving, deleting or restoring a global or an organization's item is refused, whichever route tries it:

403 Forbidden

So is creating an item for another owner, or moving one of the workspace's items to another owner. Another workspace's item answers 404, like a record that doesn't exist. An edit route answers before trying when it loads the item with find_editable_or_fail: a 404 for an item the workspace doesn't see, the 403 for one it sees but doesn't own.

Rust

LibraryGuard is a model hook, so it runs for every write of the model, inside without_events too. The refusal reaches the handler as the ORM error OrmError::Refused, whose source is the workspace error, so ? answers with the 403 above.

For an organization or the whole app

Outside a workspace request (an organization's admin screens, a command, the admin panel), any owner's items may be written, and the route decides who may. LibraryRepo::create sets the owner columns:

Rust

Whatever the owner, the three columns must agree. A global item with a workspace_id, or a workspace item without one, is refused with a 400 on scope ("a global item has no organization_id and no workspace_id") before the database sees it.

unguarded lifts the workspace rules for one deliberate write inside a workspace request, such as an organization admin publishing a workspace's template to the organization from the workspace's screen. Check the permission first:

Rust

Cloning

A workspace that wants to change a shared item clones it. clone_into copies the item into the workspace's library in the unit of work:

Rust

The copy has a new key, the workspace as its owner, cloned_from_id set to the item's key, fresh timestamps, and every other value of the item. It is saved through the model's hooks and the unit of work's change events, so the audit log records it. The workspace must see the item (global, its organization's or its own); anything else is a 404.

clone_to(&uow, id, owner) clones for another owner. An organization takes a global item, one of its own, or an item of one of its workspaces, which publishes that item to the organization; the global library takes global items. Inside a workspace request only the workspace itself may be the owner (the guard's rule), unless the call runs in unguarded.

Shaping the copy

Two methods of Library shape copies. cloning changes the copy before it is saved; cloned runs after, in the same unit of work, to copy what belongs to the item:

Rust

An error from cloned fails the clone, and the unit of work rolls back with the copy. copy.cloned_from()? reads the original's key back, and LibraryRepo::<Template>::clones_of(id) queries the clones of an item among the ones the query sees (in a workspace request, the workspace's own; chain .without_global_scope::<LibraryVisibility>() to count every owner's, for the app's admins).

The event

Once the unit of work commits, LibraryItemCloned is emitted as library.item_cloned (a clone that rolls back emits nothing):

JSON

library is the model's table; the keys are text; cloned_by is the unit of work's actor. Listen to it to count how often a template is used, or to tell its authors.

Global items from seeders

The items your product ships are global rows, placed by a seeder. seed_global matches each item to a global row by one column, creates the missing ones and updates the ones that changed, so the seeder runs again after you edit its items:

src/templates/seeder.rs

Register it with m.seeder::<TemplateLibrarySeeder>() and run it with navree db:seed template-library. Each item becomes global whatever its scope says. A row that exists keeps its key, its timestamps and its soft delete, and takes the item's other values; one that matches already is counted in SeededGlobal::unchanged. Organization and workspace items with the same slug are never touched, and neither are the clones made from a global item.

Managing global items in Helm

Helm resources wrap SeaORM entities, so a library that Helm manages derives the entity on the model's struct, as Navree's own tables do. LibraryScope is a SeaORM column type as well:

src/templates/model.rs

The resource lists, shows and edits global templates only, and creates new ones as global:

src/templates/helm.rs

Register it with m.resource::<TemplateLibraryResource>() next to m.library::<Template>(). Helm's edits and deletes run outside any workspace, so the guard lets them through; the resource's query keeps them to global rows, and the user's abilities on Template decide the rest.

Testing

The rules hold for every route of the model, so a test can check them over HTTP. With the module and a workspace in a test app:

Rust

Reference

LibraryRepo<M>

MethodReturns
visible_to(viewer)The query of what viewer sees: global items, its organization's, its workspace's
owned_by(owner)The query of owner's items
global()The query of the global items
clones_of(key)The query of the clones of item key, with the visibility scope
find_visible(exec, viewer, key)The item, if the viewer sees it
find_visible_or_fail(exec, viewer, key)The item, or a 404
find_editable_or_fail(exec, viewer, key)The item, a 404 when unseen, a 403 (library_read_only) when it belongs to a wider level than the viewer's own
create(exec, owner, item)The item, created with owner's columns
seed_global(exec, unique_by, items)SeededGlobal { created, updated, unchanged }
clone_into(uow, key, workspace)The copy, in workspace's library
clone_to(uow, key, owner)The copy, in owner's library (an organization also takes its workspaces' items)

Library

ItemDefaultDoes
SCOPE_COLUMN, ORGANIZATION_COLUMN, WORKSPACE_COLUMN, CLONED_FROM_COLUMNscope, organization_id, workspace_id, cloned_from_idName the columns
cloning(&mut self, source)nothingChanges a copy before it is saved
cloned(uow, source, copy)nothingRuns after a copy is saved, in the unit of work
library_owner()The item's LibraryOwner, read from its columns
set_library_owner(owner)Sets the three columns
cloned_from()The original's key, for a clone

Errors and the event

StatusCodeWhen
WorkspaceError::LibraryReadOnly403library_read_onlyA workspace writes an item it sees but doesn't own, creates one for another owner or moves its own away
not found404not_foundA workspace writes another workspace's item, or reads or clones one it doesn't see
WorkspaceError::Invalid on scope400validation_failedThe owner columns contradict each other
LibraryItemClonedlibrary.item_clonedA clone committed