Workspace provisioning β
Every company-layer workspace Tellia ships is data, declared in apps/agri-backend/src/workspaces/definitions/ and applied by a queue-driven service. There is one provisioning path; the seeders that used to do this (WorkspaceInitializationService, AggKnowledgeService.initializeWorkspaces, the tellia:fields DB template) are gone.
Layers, and what this page covers β
| Layer | Owner | Where |
|---|---|---|
system | Tell-ia's global catalogues (EPPO crops, pests, β¦) | workspaces/system/*.ts, applied by SystemWorkspaceBootstrapProcessor |
company | this page β what a tenant gets | workspaces/definitions/*.ts, applied by CompanyWorkspaceBootstrapProcessor |
integration / adapter-owned | FieldX, ICS | integrations/fieldx/*, partner-provisioning/ics/* |
template | admin-authored blueprints | admin/workspace-templates |
Adding or changing a definition β
One file per workspace, one export, validated at import:
export const TIMESHEET_DEFINITION = CompanyWorkspaceDefinitionSchema.parse({
key: 'tellia:timesheet', // tellia:<slug>, the identity β never rename
name: 'Timesheet', // English; what the tenant sees
icon: 'clock', // WorkspaceIcon, i.e. a shared registry name
description: 'β¦',
isResources: false, // true = Reference workspace, false = Record workspace
order: 13, // unique across definitions
activities: [COMPANY_ACTIVITY_CODES.GROWER],
tableHeader: [
/* scalar columns */
],
references: [
// resolved to workspace ids at provisioning time
{ name: 'workerName', targetKey: 'tellia:worker', description: 'β¦' },
],
} satisfies CompanyWorkspaceDefinitionInput);Then add it to definitions/all.ts. DEFINITION_HASHES is derived, so nothing else to update.
Company twins of a system catalogue (crops, pests, pesticides, β¦) do not repeat the catalogue. They call fromSystemDefinition({ key, order, activities, description }), which reads SYSTEM_WORKSPACE_DEFINITIONS, follows that entry's declared parent to fold in the base columns, and reuses its icon and label.
definitions.invariants.spec.ts enforces: unique keys and orders, icons present in the shared registry, reference targets that exist and are provisioned for the same activities, no duplicate column names, a name column on every Reference workspace, and every activity having at least one Record workspace.
Column names: the one non-obvious rule β
Two different rules apply, and they are load-bearing:
- Hand-authored columns are camelCased.
CompanyWorkspaceDefinitionSchemarunsnormalizePropertyName, son_pctis stored asnPctandrelated_cropsasrelatedCrops. This matches what the old seeder wrote (it parsed through the same schema), so existing tenant records still line up with their columns. - Columns taken from a system catalogue keep the catalogue's names. The system bootstrap does not parse through that schema, so
tellia:cropsstoresscientific_names. Federation unions columns by name across layers, so the company twin must not camelCase them.fromSystemDefinitionrestores the source names after validating.
definitions.invariants.spec.ts compares company twins against the catalogue, and definition-i18n.spec.ts checks the i18n files use the stored names β otherwise a translation silently falls back to English.
What provisioning does to an existing workspace β
CompanyWorkspaceProvisioningService.provisionCompany runs two passes: scalar columns first, so every key has an id, then reference columns resolved from a map built for that company only (a reference can never point at another tenant's workspace).
Workspace.provisioning records what the seeder last wrote β defHash, locale, name, description, and per-column values. That is what makes a re-provision safe:
| Behaviour | |
|---|---|
| Row matches its definition hash and locale | skipped, no write, no audit entry |
| Tenant renamed the workspace | rename kept |
| Tenant edited a column label or description | edit kept |
Tenant tuned required, sortable, filterable, width, ai | kept |
Column type, arrayType, workspaceId | definition-owned, always refreshed |
| Tenant added a column | kept, after the definition's columns |
| Tenant deleted a definition column | stays deleted, never resurrected |
Legacy row with no provisioning subdoc | first pass keeps every value the row already holds and records the definition's own values as the baseline; a later re-provision then refreshes anything that matched the definition and leaves anything that diverged (it now reads as a tenant edit) |
| Legacy row whose name is not a spelling of the definition's | kept forever. On a row we have never written, the only rename allowed is a spelling fix (case, trailing plural: "Crop" β "Crops"). Anything further β "Training Session Log", or "CΓ©pages" on a tenant whose locale says en β is theirs |
Row with an adapterKey (FieldX, ICS) | skipped β that seeder owns it |
Row with no key, or a pre-tellia: key (reminders, generic) | left alone. Re-keying a tenant's row is WorkspaceKeyMigrationService's job, not provisioning's; the definition is created alongside it under the canonical key |
Row with the definition's name under another tellia: key | skipped, counted as nameConflicts |
Nothing is ever deleted. Removing a definition stops maintaining those workspaces; it does not remove them from tenants who have them.
The queue β
company-workspace-bootstrap, concurrency 1, one job:
| Job | Trigger | Does |
|---|---|---|
provision | company creation, admin re-init | one company |
There is deliberately no sweep. A definition change reaches an existing tenant only when someone asks for it β never on a timer. Provisioning runs where WorkspaceInitializationService ran: once, at company creation. Rolling a definition change out to tenants who already exist is a migration someone runs on purpose, not a background job, because it would otherwise hand ~5 workspaces per tenant to companies that never asked for them.
Company creation only enqueues, so it never depends on ~20 workspace writes.
translate (its own queue) fills missing definition translations; trigger it from POST /admin/acquisition/workspace-definitions/translate.
Translations β
Strings resolve checked-in i18n JSON β generated cache β English:
src/i18n/translations/<locale>.jsonunderworkspaces.<scope>.β¦, where<scope>is the key withouttellia:. Hand-written, highest quality.workspace_definition_translations, unique on(defKey, defHash, locale). Keyed by the definition hash, so changing a definition asks for fresh strings rather than serving stale ones. Platform-wide: translated once, reused by every tenant.- The English definition.
Resolution never throws. A definition shipped today serves English until the translate job lands, then the next re-provision applies the translation (the stored provisioning.locale and values no longer match, so the row is refreshed).
While a locale is falling back, isEnglishFallback is set and provisioning will not write the name or description of a workspace that already exists β otherwise a French tenant's "Parcelles" would become "Fields" for the cycle before the fill runs, and the seven company twins of a system catalogue (crops, pests, β¦) have no translation at all, so it would be permanent. New workspaces are still created with the English strings.
POST /admin/acquisition/workspace-definitions/translate with { locales?, force? } runs a pass by hand β a new locale, or force after a bad batch.
Operating it β
{app="tellia-agri-backend"} |= "Company workspaces provisioned"
{app="tellia-agri-backend"} |= "Provisioned a definition on request"
{app="tellia-agri-backend"} |= "Workspace name already taken under another key"A re-provision after a definition change writes once per drifted workspace, then the hash guard makes further runs free.