Workspaces and Settings

Every non-trivial application requires some level of configuration to customize its run-time behavior and help it locate data resources required for it to perform its functions. An iTwin.js Workspace comprises the Settings that supply this configuration and the WorkspaceContainers that provide those resources. Settings dictionaries inside of Workspace.settings provide values for individual settings, some of which are configured to point to one or more WorkspaceDbs that provide resources for particular purposes. The anatomy of a Workspace is illustrated below:

Anatomy of a Workspace

To explore Workspace concepts, let's take the example of an imaginary application called "LandscapePro" that allows users to decorate an iModel by adding landscaping features like trees, shrubs, flower beds, and patio furniture.

Settings

Settings are how administrators of an application or project configure the workspace for end-users. Be careful to avoid confusing them with "user preferences", which can be configured by individual users. For example, an application might provide a check box to toggle "dark mode" on or off. Each individual user can make their own choice as to whether they want to use this mode - it is a user preference, not a setting. But an administrator may define a setting that controls whether users can see that check box in the first place.

A Setting is simply a name-value pair. The value can be of one of the following types:

  • A string, number, or boolean;
  • An object containing properties of any of these types; or
  • An array containing elements of one of these types.

A SettingName must be unique, 1 to 1024 characters long with no leading nor trailing whitespace, and should begin with the schema prefix of the schema that defines the setting. For example, LandscapePro might define the following settings:

  "landscapePro/ui/defaultToolId"
  "landscapePro/ui/availableTools"
  "landscapePro/flora/preferredStyle"
  "landscapePro/flora/treeDbs"
  "landscapePro/hardinessRange"

Each setting's name begins with the "landscapePro" schema prefix followed by a forward slash. Forward slashes are used to create logical groupings of settings, similar to how file paths group files into directories. In the above example, "ui" and "flora" are two separate groups containing two settings each, while "hardinessRange" is a top-level setting. An application user interface that permits the user to view or edit settings would probably present these groups as individual nodes in a tree view, or as tabs.

Settings schemas

The metadata describing a group of related Settings is defined in a SettingGroupSchema. The schema is based on JSON Schema, with the following additions:

  • schemaPrefix (required) - a unique name for the schema. All of the names in the schema inherit this prefix.
  • description (required) - a description of the schema appropriate for displaying to a user.
  • settingDefs - an object consisting of SettingSchemas describing individual Settings, indexed by their SettingNames.
  • typeDefs - an object consisting of SettingSchemas describing reusable types of Settings that can be referenced by SettingSchemas in this or any other schema.
  • order - an optional integer used to sort the schema in a user interface that lists multiple schemas, where schemas of lower order sort before those with higher order.

We can define the LandscapePro schema programmatically as follows:

const schema: SettingGroupSchema = {
  schemaPrefix: "landscapePro",
  description: "LandscapePro configuration settings",
  settingDefs: {
    "flora/preferredStyle": {
      type: "string",
      description: "The name of one of a set of predefined 'styles' of foliage that might be used to select appropriate flora",
    },
    "flora/treeDbs": {
      type: "array",
      extends: "itwin/core/workspace/workspaceDbList",
    },
    "ui/defaultTool": {
      type: "string",
      description: "Id of the tool that is active when the application starts",
    },
    "ui/availableTools": {
      type: "array",
      description: "Ids of tools that should be shown to the user",
      items: {
        type: "string",
      },
      combineArray: true,
    },
    "hardinessRange": {
      type: "object",
      description: "Specifies the upper and lower limits on the hardiness zone for flora that can survive in a region",
      properties: {
        minimum: {
          type: "integer",
          extends: "landscapePro/hardinessZone",
          description: "The lower limit on hardiness zone for flora that can survive in a region",
        },
        maximum: {
          type: "integer",
          extends: "landscapePro/hardinessZone",
          description: "The upper limit on hardiness zone for flora that can survive in a region",
        },
      },
    },
  },
  typeDefs: {
    hardinessZone: {
      type: "integer",
      description: "A USDA hardiness zone used to describe the surivability of plants in a geographical region",
      minimum: 0,
      maximum: 13,
    },
  },
};

This schema defines 5 settingDefs and 1 typeDef. Note the "landscapePro" schema prefix, which is implicitly included in the name of each settingDef and typeDef in the schema - for example, the full name of the "hardinessRange" setting is "landscapePro/hardinessRange".

The "hardinessZone" typeDef represents a USDA hardiness zone as an integer between 0 and 13. The "hardinessRange" settingDef reuses that typeDef for both its "minimum" and "maximum" properties by declaring that each extends that type. Note that extends requires the schema prefix to be specified, even within the same schema that defines the typeDef.

The "flora/treeDbs" settingDef extends the "workspaceDbList" typeDef from a different schema - the workspace schema delivered with the application, with the "itwin/core/workspace" schema prefix.

Registering schemas

Schemas enable the application to validate that the setting values loaded at run-time match the expected types - for example, if we try to retrieve the value of the "landscapePro/ui/defaultToolId" setting and discover a number where we expect a string, an exception will be thrown. They can also be used by user interfaces that allow administrators to configure settings by enforcing types and other constraints like the one that requires "hardinessZone" to be an integer between 0 and 13. To do this, the schema must first be registered.

The set of currently-registered schemas can be accessed via IModelHost.settingsSchemas. You can register new ones in a variety of ways. Most commonly, applications will deliver their schemas in JSON files, in which case they can use SettingsSchemas.addFile to supply a single JSON file or SettingsSchemas.addDirectory to supply a directory full of them. In our case, however, we've defined the schema programmatically, so we'll register it using SettingsSchemas.addGroup:

IModelHost.settingsSchemas.addGroup(schema);

Your application should register its schemas shortly after invoking IModelHost.startup. Registering a schema adds its typeDefs and settingDefs to SettingsSchemas.typeDefs and SettingsSchemas.settingDefs, respectively. It also raises the SettingsSchemas.onSchemaChanged event. All schemas are unregistered when IModelHost.shutdown is invoked.

Settings dictionaries

The values of Settings are provided by SettingsDictionarys. The Settings for the current session can be accessed via the settings property of IModelHost.appWorkspace. You can add new dictionaries to provide settings values at any time during the session, although most dictionaries will be loaded shortly after IModelHost.startup.

Let's load a settings dictionary that provides values for some of the settings in the LandscapePro schema:

const values: SettingsContainer = {
  "landscapePro/ui/defaultTool": "place-shrub",
  "landscapePro/ui/availableTools": ["place-shrub", "place-koi-pond", "apply-mulch"],
};

const props: SettingsDictionaryProps = {
  // A unique name for this dictionary.
  name: "LandscapeProDefaults",
  // This dictionary's priority relative to other dictionaries.
  priority: SettingsPriority.defaults,
};

IModelHost.appWorkspace.settings.addDictionary(props, values);

Now you can access the setting values defined in the dictionary via IModelHost.appWorkspace.settings:

let defaultTool = IModelHost.appWorkspace.settings.getString("landscapePro/ui/defaultTool"); // "place-shrub"
let availableTools = IModelHost.appWorkspace.settings.getArray<string>("landscapePro/ui/availableTools"); // ["place-shrub", "place-koi-pond", "apply-mulch"]
let preferredStyle = IModelHost.appWorkspace.settings.getString("landscapePro/flora/preferredStyle"); // undefined
const preferredStyleOrDefault = IModelHost.appWorkspace.settings.getString("landscapePro/flora/preferredStyle", "default"); // "default"

Note that getString returns undefined for "landscapePro/preferredStyle" because our dictionary didn't provide a value for it. The overload of that function (and similar functions like Settings.getBoolean and Settings.getObject) allows you to specify a default value to use if the value is not defined.

Note: In general, avoid caching the values of individual settings - just query them each time you need them, because they can change at any time. If you must cache (for example, if you are populating a user interface from the setting values), listen for and react to the Settings.onSettingsChanged event.

Any number of dictionaries can be added to Workspace.settings. Let's add another one:

IModelHost.appWorkspace.settings.addDictionary({
  name: "LandscapeProOverrides",
  priority: SettingsPriority.application,
}, {
  "landscapePro/flora/preferredStyle": "coniferous",
  "landscapePro/ui/defaultTool": "place-koi-pond",
  "landscapePro/ui/availableTools": ["place-gazebo", "apply-mulch"],
});

This dictionary adds a value for "landscapePro/flora/preferredStyle", and defines new values for the two settings that were also defined in the previous dictionary. See what happens when we look up those settings' values again:

defaultTool = IModelHost.appWorkspace.settings.getString("landscapePro/ui/defaultTool"); // "place-koi-pond"
availableTools = IModelHost.appWorkspace.settings.getArray<string>("landscapePro/ui/availableTools"); // ["place-gazebo", "apply-mulch", "place-shrub", "place-koi-pond"]
preferredStyle = IModelHost.appWorkspace.settings.getString("landscapePro/flora/preferredStyle"); // "coniferous"

Now, as expected, "landscapePro/flora/preferredStyle" is no longer undefined. The value of "landscapePro/ui/defaultTool" has been overwritten with the value specified by the new dictionary. And the "landscapePro/ui/availableTools" array now has the merged contents of the arrays defined in both dictionaries. What rules determine how the value of a setting is resolved when multiple dictionaries provide a value for it? The answer lies in the dictionaries' SettingsPrioritys.

Settings priorities

Configurations are often layered: an application may ship with built-in default settings, that an administrator may selectively override for all users of the application. Beyond that, additional configuration may be needed on a per-organization, per-iTwin, and/or per-iModel level. SettingsPriority defines which dictionaries' settings take precedence over others - the dictionary with the highest priority overrides any other dictionaries that provide a value for a given setting.

A SettingsPriority is just a number, but specific values carry semantics:

SettingsDictionarys of application priority or lower reside in IModelHost.appWorkspace. Those of higher priority are stored in an IModelDb.workspace - more on those shortly.

What about the "landscapePro/ui/availableTools" array? In the LandscapePro schema, the corresponding settingDef has SettingSchema.combineArray set to true, meaning that - when multiple dictionaries provide a value for the setting - instead of being overridden, they are merged together to form a single array, eliminating duplicates, and sorted in descending order by dictionary priority.

iModel settings

So far, we have been working with IModelHost.appWorkspace. But - as mentioned above - each IModelDb has its own workspace as well, with its own Settings that can override and/or supplement the application workspace's settings. These settings are stored as SettingsDictionarys in the iModel's be_Props table. When the iModel is opened, its Workspace.settings are populated from those dictionaries. So, an application is working in the context of a particular iModel, it should resolve setting values by asking IModelDb.workspace, which will fall back to IModelHost.appWorkspace if the iModel's settings dictionaries don't provide a value for the requested setting.

Since an iModel is located in a specific geographic region, LandscapePro wants to limit the selection of foliage based on the USDA hardiness zone(s) in which the iModel resides. An administrator could configure the hardiness zone of an iModel as follows:

interface HardinessRange {
  minimum: number;
  maximum: number;
}

const range: HardinessRange = { minimum: 6, maximum: 8 };
await iModel.acquireSchemaLock();
iModel.saveSettingDictionary("landscapePro/iModelSettings", {
  "landscapePro/hardinessRange": range,
});

Note that modifying the iModel settings requires obtaining an exclusive write lock on the entire iModel. Ordinary users should never perform this kind of operation - only administrators.

The next time we open the iModel, the new settings dictionary will automatically be loaded, and we can query its settings:

const hardinessRange = iModel.workspace.settings.getObject<HardinessRange>("landscapePro/hardinessRange");
// returns { minimum: 8, maximum: 10 }
defaultTool = iModel.workspace.settings.getString("landscapePro/ui/defaultTool");
// returns "place-koi-pond" as specified by IModelHost.appWorkspace.settings.

The "hardinessRange" setting is obtained from the iModel's settings dictionary, while the "defaultTool" falls back to the value defined in IModelHost.appWorkspace.settings.

Workspace resources

"Resources" are bits of data that an application depends on at run-time to perform its functions. The kinds of resources can vary widely from one application to another, but some common examples include:

It might be technically possible to store resources in Settings, but doing so would present significant disadvantages:

  • Some resources, like images and fonts, may be defined in a binary format that is inefficient to represent using JSON.
  • Some resources, like geographic coordinate system definitions, must be extracted to files on the local file system before they can be used.
  • Some resources may be large, in size and/or quantity.
  • Resources can often be reused across many projects, organizations, and iModels.
  • Administrators often desire for resources to be versioned.
  • Administrators often want to restrict who can read or create resources.

To address these requirements, workspace resources are stored in immutable, versioned CloudSqlite databases called WorkspaceDbs, and Settings are configured to enable the application to locate those resources in the context of a session and - if relevant - an iModel.

A WorkspaceDb can contain any number of resources of any kind, where "kind" refers to the purpose for which it is intended to be used. For example, fonts, text styles, and images are different kinds of resources. Each resource must have a unique name, between 1 and 1024 characters in length and containing no leading or trailing whitespace. A resource name should incorporate a schemaPrefix and an additional qualifier to distinguish between different kinds of resources stored inside the same WorkspaceDb. For example, a database might include text styles named "itwin/textStyles/styleName" and images named "itwin/patternMaps/imageName". Prefixes in resource names are essential unless you are creating a WorkspaceDb that will only ever hold a single kind of resource.

Ultimately, each resource is stored as one of three underlying types:

  • A string, which quite often is interpreted as a serialized JSON object. Examples include text styles and settings dictionaries.
  • A binary blob, such as an image.
  • An embedded file, like a PDF file that users can view in a separate application.

String and blob resources can be accessed directly using WorkspaceDb.getString and WorkspaceDb.getBlob. File resources must first be copied onto the local file system using WorkspaceDb.getFile, and should be avoided unless they must be used with software that requires them to be accessed from disk.

WorkspaceDbs are stored in access-controlled WorkspaceContainers backed by cloud storage. So, the structure of a Workspace is a hierarchy: a Workspace contains any number of WorkspaceContainers, each of which contains any number of WorkspaceDbs, each of which contains any number of resources. The container is the unit of access control - anyone who has read access to the container can read the contents of any WorkspaceDb inside it, and anyone with write access to the container can modify its contents.

Creating workspace resources

Note: Creating and managing data in workspaces is a task for administrators, not end-users. Administrators will typically use a specialized application with a user interface designed for this task. For the purposes of illustration, the following examples will use the WorkspaceEditor API directly.

LandscapePro allows users to decorate a landscape with a variety of trees and other flora. So, trees are one of the kinds of resources the application needs to access to perform its functions. Naturally, they should be stored in the Workspace. Let's create a WorkspaceDb to hold trees of the genus Cornus.

Since every WorkspaceDb must reside inside a WorkspaceContainer, we must first create a container. Creating a container also creates a default WorkspaceDb. In the createTreeDb function below, we will set up the container's default WorkspaceDb to be an as-yet empty tree database.

const editor = WorkspaceEditor.construct();

async function createTreeDb(genus: string): Promise<EditableWorkspaceDb> {
  const label = `Trees ${genus}`;
  const description = `Trees of the genus ${genus}`;
  const container: EditableWorkspaceContainer = await editor.createNewCloudContainer({
    // A description of the new `CloudSQLite.Container` for use as a `Workspace` container.
    metadata: {
      label: `Workspace for {label}`,
      description,
    },
    // Ownership and datacenter are defined by the iTwin. Access rights are granted by RBAC administrators of the iTwin.
    scope: { iTwinId },
    // The manifest to be embedded inside the default WorkspaceDb.
    manifest: {
      // A user-facing name for the WorkspaceDb.
      workspaceName: label,
      // A description of the WorkspaceDb's contents and purpose.
      description,
      // The name of someone (typically an administrator) who can provide help and information
      // about this WorkspaceDb.
      contactName: "Lief E. Greene",
    },
  });

  container.acquireWriteLock("Lief E. Greene");

  return container.getEditableDb({});
}

Now, let's define what a "tree" resource looks like, and add some of them to a new WorkspaceDb. To do so, we'll need to make a new version of the empty "cornus" WorkspaceDb we created above. WorkspaceDbs use semantic versioning, starting with a pre-release version (0.0.0). Each version of a given WorkspaceDb becomes immutable once published to cloud storage, with the exception of pre-release versions. The process for creating a new version of a WorkspaceDb is as follows:

  1. Acquire the container's write lock. Only one person - the current holder of the lock - can make changes to the contents of a given container at any given time.
  2. Create a new version of an existing WorkspaceDb.
  3. Open the new version of the db for writing.
  4. Modify the contents of the db.
  5. Close the db.
  6. (Optionally, create more new versions of WorkspaceDbs in the same container).
  7. Release the container's write lock.

Once the write lock is released, the new versions of the WorkspaceDbs are published to cloud storage and become immutable. Alternatively, you can discard all of your changes via EditableWorkspaceContainer.abandonChanges - this also releases the write lock.

Semantic versioning and immutability of published versions are core features of Workspaces. Newly created WorkspaceDbs start with a pre-release version that bypasses these features. Therefore, after creating a WorkspaceDb, administrators should load it with the desired resources and then publish version 1.0.0. Pre-release versions are useful when making work-in-progress adjustments or sharing changes prior to publishing a new version.

interface TreeResource {
  commonName: string;
  hardiness: HardinessRange;
  light: "full" | "shade" | "partial";
}

function addTree(treeDb: EditableWorkspaceDb, species: string, tree: TreeResource): void {
  // We use a prefix to distinguish trees from other kinds of resources that might be present in the same WorkspaceDb.
  const resourceName = `landscapePro/tree/${species}`;
  treeDb.addString(resourceName, JSON.stringify(tree));
}

let cornusDb = await createTreeDb("cornus");
cornusDb.open();

addTree(cornusDb, "alternifolia", {
  commonName: "Pagoda Dogwood",
  hardiness: { minimum: 4, maximum: 8 },
  light: "full",
});

addTree(cornusDb, "asperifolia", {
  commonName: "Roughleaf Dogwood",
  hardiness: { minimum: 9, maximum: 9 },
  light: "full",
});

// Close the db and release the write lock, which publishes the changes to the cloud and makes
// them visible to other users.
cornusDb.close();
cornusDb.container.releaseWriteLock();

// We have just created and populated a prerelease version (0.0.0) of the cornusDb.
// Let's mint version 1.0.0.
// As before, the write lock must be acquired and released, and the db must be opened and closed
// to publish the changes to the cloud.
cornusDb.container.acquireWriteLock("Lief E. Greene");
const cornusMajorProps = (await cornusDb.container.createNewWorkspaceDbVersion({
  versionType: "major",
})).newDb;
cornusDb = cornusDb.container.getEditableDb(cornusMajorProps);
cornusDb.open();
cornusDb.close();
cornusDb.container.releaseWriteLock();

In the example above, we created version 1.1.0 of the "cornus" WorkspaceDb, added two species of dogwood tree to it, and uploaded it. Later, we might create a patched version 1.1.1 that includes a species of dogwood that we forgot in version 1.1.0, and add a second WorkspaceDb to hold trees of the genus abies:

cornusDb.container.acquireWriteLock("Lief E. Greene");
const cornusPatchProps = (await cornusDb.container.createNewWorkspaceDbVersion({
  versionType: "patch",
})).newDb;

cornusDb = cornusDb.container.getEditableDb(cornusPatchProps);
cornusDb.open();
addTree(cornusDb, "racemosa", {
  commonName: "Northern Swamp Dogwood",
  hardiness: { minimum: 4, maximum: 9 },
  light: "full",
});
cornusDb.close();
cornusDb.container.releaseWriteLock();

let abiesDb = await createTreeDb("abies");
abiesDb.open();
addTree(abiesDb, "amabilis", {
  commonName: "Pacific Silver Fir",
  hardiness: { minimum: 5, maximum: 5 },
  light: "full",
});
addTree(abiesDb, "balsamea", {
  commonName: "Balsam Fir",
  hardiness: { minimum: 3, maximum: 6 },
  light: "full",
});
abiesDb.close();
abiesDb.container.releaseWriteLock();

// Mint 1.0.0 of abiesDb
abiesDb.container.acquireWriteLock("Lief E. Greene");
const abiesMajorProps = (await abiesDb.container.createNewWorkspaceDbVersion({
  versionType: "major",
})).newDb;
abiesDb = abiesDb.container.getEditableDb(abiesMajorProps);
abiesDb.open();
abiesDb.close();
abiesDb.container.releaseWriteLock();

Note that we created one WorkspaceContainer to hold versions of the "cornus" WorkspaceDb, and a separate container for the "abies" WorkspaceDb. Alternatively, we could have put both WorkspaceDbs into the same container. However, because access control is enforced at the container level, maintaining a 1:1 mapping between containers and WorkspaceDbs simplifies things and reduces contention for the container's write lock.

Accessing workspace resources

Now that we have some WorkspaceDbs, we can configure our Settings to use them. The LandscapePro schema defines a "landscapePro/flora/treeDbs" setting that extends the type itwin/core/workspace/workspaceDbList. This type defines an array of WorkspaceDbProps, and overrides the combineArray property to true. So, a setting of this type can be resolved to a list of WorkspaceDbs, sorted by SettingsPriority, from which you can obtain resources.

Let's write a function that produces a list of all of the available trees that can survive in a specified USDA hardiness zone:

async function getAvailableTrees(hardiness: HardinessRange): Promise<TreeResource[]> {
  // Resolve the list of WorkspaceDbs from the setting.
  const dbs = await iModel.workspace.getWorkspaceDbs({ settingName: "landscapePro/flora/treeDbs" });

  // Query for all the trees in all the WorkspaceDbs and collect a list of those that match the hardiness criterion.
  const trees: TreeResource[] = [];
  Workspace.queryResources({
    dbs,
    // Include only tree resources, as indicated by their name prefix.
    namePattern: "landscapePro/tree/%",
    nameCompare: "LIKE",
    callback: (resources: Iterable<{ name: string, db: WorkspaceDb }>) => {
      for (const resource of resources) {
        // Look up the tree as stringified JSON in the current WorkspaceDb.
        const str = resource.db.getString(resource.name);
        assert(undefined !== str);
        const tree = JSON.parse(str) as TreeResource;
        if (tree.hardiness.minimum <= hardiness.maximum && hardiness.minimum <= tree.hardiness.maximum) {
          trees.push(tree);
        }
      }
    },
  });

  return trees;
}

Now, let's configure the "landscapePro/flora/treeDbs" setting to point to the two WorkspaceDbs we created, and use the getAvailableTrees function to retrieve TreeResources from it:

assert(undefined !== cornusDb.cloudProps);

// Point the setting at the cornus WorkspaceDb.
iModel.workspace.settings.addDictionary({
  name: "LandscapePro Trees",
  priority: SettingsPriority.iModel,
}, {
  "landscapePro/flora/treeDbs": [
    { ...cornusDb.cloudProps },
    { ...abiesDb.cloudProps },
  ],
});

const anyHardiness: HardinessRange = { minimum: 0, maximum: 13 };

let allTrees = await getAvailableTrees(anyHardiness);

// Roughleaf Dogwood excluded because its hardiness range (9, 9) is outside of the iModel's range (6, 8).
const iModelTrees = await getAvailableTrees(iModel.workspace.settings.getObject<HardinessRange>("landscapePro/hardinessRange", anyHardiness));

In the example above, allTrees includes all five tree species from the two genuses, because they all fall within the hardiness range (0, 13). iModelTrees excludes the Roughleaf Dogwood and Pacific Silver Fir, because their hardiness ranges of (9, 9) and (5, 5) do not intersect the iModel's hardiness range (6, 8).

Note that we configured the setting to point to the patch 1.1.1 version of the cornus WorkspaceDb that added the Northern Swamp Dogwood. If we had omitted the WorkspaceDbProps.version property, it would have defaulted to the latest version - in this case, 1.1.1 again, but if in the future we created a new version, that would become the new "latest" version and automatically get picked up for use.

If we configure the setting to use version 1.1.0, then allTrees will not include the Northern Swamp Dogwood added in 1.1.1:

iModel.workspace.settings.addDictionary({
  name: "LandscapePro Trees",
  priority: SettingsPriority.iModel,
}, {
  "landscapePro/flora/treeDbs": [
    {
      ...cornusDb.cloudProps,
      version: "1.0.0",
    },
    { ...abiesDb.cloudProps },
  ],
});

allTrees = await getAvailableTrees(anyHardiness);

We could also configure the version more precisely using semantic versioning rules to specify a range of acceptable versions. When compatible new versions of a WorkspaceDb are published, the workspace would automatically consume them without requiring any explicit changes to its Settings.

It may be tempting to "optimize" by calling getAvailableTrees once when your application starts up and caching the result to reuse throughout the session, but remember that the list of trees is determined by a setting, and settings can change at any time during the session. If you must cache, make sure you listen for the Settings.onSettingsChanged event to be notified when your cache may have become stale.

Last Updated: 06 August, 2024