App Loaders
Loaders define what supporting records an app fetches — contacts, jobs, sites, certificates, working patterns, etc. Each loader is a Custom Metadata record holding a small JSON definition of a query. The query runs entirely in Apex; the browser only ever receives the results.
Two Custom Metadata Types exist, one per surface:
| CMT | Used by | How records are matched to the app |
|---|---|---|
b3s__Scheduling_Loader__mdt
|
the Scheduler | b3s__Object_Name__c must equal the context object API name (e.g. Account) or the sharing-group name the scheduler was opened with.
|
b3s__Component_Loader__mdt
|
all component apps (Clock, Terminal, Timesheeting, Client Shift Manager, …) | linked to a parent b3s__Component_Configuration__mdt; every loader under the app's configuration loads.
|
Both share the same JSON contract.
What changed in Version 10
Loaders are now defined only via the JSON field (b3s__JSON__c). The JSON format is declarative (no code) and is executed entirely server-side.
Do I have to do anything?
Yes. The legacy Javascript Code field (b3s__Javascript_Code__c) is no longer read — its content is ignored regardless of what it contains.
A loader record whose JSON field is empty (or invalid) now fails the app's boot metadata call with an error naming the record:
Loader <DeveloperName> has no b3s__JSON__c setup
Migrate every loader to the JSON field (or delete unused loader records) before upgrading.
How to migrate a loader
Old format (Javascript Code field)
{
name: "contacts",
objectName: "Contact",
filterRecordsBy: function () {
return "Id IN (SELECT b3o__Candidate__c FROM b3o__Placement__c WHERE b3o__Job__r.b3o__Client_Account__c = {0})"
},
groupRecordsBy: "Id",
fieldToLoad: ['Name', 'Email'],
filteringItems: function () {
return [`'${contextRecordId}'`]
}
}
New format (JSON field)
{
"name": "contacts",
"objectName": "Contact",
"filterRecordsBy": "Id IN (SELECT b3o__Candidate__c FROM b3o__Placement__c WHERE b3o__Job__r.b3o__Client_Account__c = :contextId)",
"groupRecordsBy": "Id",
"fieldToLoad": ["Name", "Email"]
}
Key differences:
| Old (JS) | New (JSON) |
|---|---|
filterRecordsBy is a function returning a string
|
filterRecordsBy is a string (or an array of strings for multiple filters)
|
filteringItems supplies values for {0}, {1} …
|
Removed. Use named placeholders directly in the filter (see below) |
'${contextRecordId}' / {0}
|
:contextId
|
JSON field reference
| Property | Required | Description |
|---|---|---|
name
|
✅ | Unique loader name used by the app to read the loaded records. |
objectName
|
✅ | API name of the object to query (e.g. Contact, b3s__Certificate_Requirement__c).
|
filterRecordsBy
|
✅ | A SOQL WHERE-clause string, or an array of clause strings (each runs as a separate query; results are merged and de-duplicated by Id).
|
fieldToLoad
|
— | Array of field API names to retrieve. |
groupRecordsBy
|
— | Field API name to group results by. Use * to combine multiple fields into one key (e.g. b3s__Account__c*b3s__Site__c).
|
label
|
— | Friendly name used in error messages. |
Available placeholders (merge fields)
Use these directly inside filterRecordsBy. They are automatically replaced and correctly quoted/escaped for SOQL:
| Placeholder | Resolves to |
|---|---|
:contextId
|
The record the app is opened on (e.g. the Account/Job record id) |
:contactUserId
|
The current contact/user id |
:userId
|
Same as :contactUserId
|
:contextRecord.<FieldApiName>
|
A field value from the context record (e.g. :contextRecord.b3s__Region__c)
|
:contextUser.<FieldApiName>
|
A field value from the context user record |
:contextStart
|
Start of the calendar range currently being viewed |
:contextEnd
|
End of the calendar range currently being viewed |
:contextStart-Nd / :contextStart+Nd
|
The window start shifted by N days, as a SOQL datetime (e.g. :contextStart-7d = 7 days before the window). Same forms work on :contextEnd. Use these to load records around, not just inside, the visible window.
|
:contextDate
|
The context date |
Examples
"filterRecordsBy": "Id = :contactUserId"
"filterRecordsBy": "b3s__Account__c = :contextId AND b3s__Active__c = true"
"filterRecordsBy": [
"b3s__Account__c = :contextId",
"b3s__Region__c = :contextRecord.b3s__Region__c"
]
Note: Do not wrap placeholders in quotes yourself —
Id = :contactUserIdis correct, notId = ':contactUserId'. The system adds quotes automatically where needed.
Deployment notes for the admin
- The new
JSONfield (b3s__JSON__c) must exist on bothb3s__Scheduling_Loader__mdtandb3s__Component_Loader__mdt. This is included in the managed package upgrade — no manual field creation needed. - After editing a loader's
JSONvalue (and clearingJavascript Code), users may need to hard-refresh the app to pick up the change, as loader metadata can be cached. - To validate a migration, open the app with the browser console visible: the deprecation warning should disappear, and the related data (e.g. contacts/certificates) should load as before.
1. JSON-only — the legacy JavaScript form is gone
A loader is defined only in the b3s__JSON__c field. The old b3s__Javascript_Code__c snippet form is no longer read at all — its filterRecordsBy functions, filteringItems, {0} positional tags and closure variables (contextRecordId, events, …) do not exist anymore.
⚠️ A loader record with an empty or invalid
b3s__JSON__cis a boot error — the app's metadata call fails withLoader <DeveloperName> has no b3s__JSON__c setup(or a parse error). Migrate or delete every legacy record before upgrading.
2. How a loader executes (the footprint)
The key design point: the query definition never reaches the browser.
- Boot — the app calls
DataLoadingService.getSupportingMetadata. For each matching loader the client receives only a descriptor:developerName,name,label,objectName,groupRecordsBy.filterRecordsBy,fieldToLoadandapexFilterstay server-side. - Load — for each descriptor the client calls
DataLoadingService.getLoaderDatawith the CMTDeveloperNameplus its context (record id, user, calendar window). Apex re-reads the JSON from the CMT, merges the placeholders, and runs the query. - Stream — results come back in Id-cursored pages (see Batching below) and land in a client-side loader state:
{ records: [...], grouped: { key: [...] } }, keyed by the loader'sname. App configuration references loaders by thatname.
Because the definition is read server-side by DeveloperName, the client cannot alter the query — it can only supply the context values, and even those are validated/escaped in Apex.
Access level. The permissions a loader runs with are derived from its owning app, a server-side fact:
- Scheduling loaders always run as the logged-in user (
USER_MODE— CRUD/FLS/sharing enforced) and never elevate for guests. - Component loaders classify by their parent configuration's
b3s__Type__c. On portal apps, guests with a validsessionKey(or a kioskhostType) run elevated (SYSTEM_MODE, custom objects + Contact/Account only); everyone else runsUSER_MODE.
Practical consequence: in the scheduler, a loader returns only records the running user can see — if two users see different row counts, check sharing before checking the loader.
3. JSON reference
{
"name": "contacts",
"label": "Workers",
"objectName": "Contact",
"fieldToLoad": ["Name", "Email", "MailingLatitude", "MailingLongitude"],
"groupRecordsBy": "Id",
"filterRecordsBy": "Id IN (SELECT b3s__Contact__c FROM b3s__Group_Assignment__c WHERE b3s__Group__c = :contextId)"
}
| Property | Required | Description |
|---|---|---|
name
|
✅ | Unique loader name — this is what app configurations and components reference. (The CMT DeveloperName is what the server looks the record up by.)
|
objectName
|
✅ | API name of the object to query. |
filterRecordsBy
|
✅* | SOQL WHERE-clause string, or an array of strings. Each array entry runs as a separate query; results are merged and de-duplicated by Id. *Optional when apexFilter is set.
|
fieldToLoad
|
— | Array of field API names / relationship paths to retrieve. Id is always included. Every entry is identifier-validated server-side.
|
groupRecordsBy
|
— | Field to group the results by on the client (grouped state). Combine fields with * for a composite key, e.g. b3s__Account__c*b3s__Site__c.
|
label
|
— | Friendly name used in error messages / UI. |
apexFilter
|
— | Name of an Apex FilterPlugin class (e.g. "b3s.GroupShiftLoader") — see Apex filters below.
|
Notes:
- A clause that contains its own
LIMITruns verbatim as a single page (no cursor pagination for that clause). ORDER BYinside a clause is honored; the server injectsId ASCinto it for stable paging.
4. Available variables (merge placeholders)
Use these inside filterRecordsBy. They are resolved in Apex, with values quoted and SOQL-escaped automatically — never add quotes yourself (Id = :contactUserId, not Id = ':contactUserId').
| Placeholder | Resolves to |
|---|---|
:contextId
|
Id of the record the app was opened on (Account, Job, sharing Group, …). |
:contactUserId
|
The acting contact's Id. For portal guests this is pinned to the sessionKey's contact server-side — a client cannot substitute someone else's. |
:userId
|
Alias of :contactUserId.
|
:contextRecord.<FieldApiName>
|
A field value from the context record, resolved server-side and escaped by type (e.g. :contextRecord.b3s__Region__c). Relationship paths are supported.
|
:contextUser.<FieldApiName>
|
A field value from the acting contact's record. |
:contextStart
|
Start of the date window being loaded (the calendar's visible range / the app's fetch window), as a SOQL datetime. |
:contextEnd
|
End of that window. |
:contextDate
|
The single context date some component apps send (YYYY-MM-DD). Validated against a strict date charset.
|
Resolution rules worth knowing:
- An unresolvable placeholder (no context record, no user) becomes an empty-string literal — the clause safely matches nothing, it never turns into
= null. - The legacy
filteringItemsfunction and positional{0}tags are not part of the JSON contract and are not honored.
5. Apex filters
When a WHERE clause can't express the filter (typically multi-level semi-joins, which SOQL rejects), a loader — or an app-definition schedulable — can name an Apex class instead:
{
"name": "groupPatterns",
"objectName": "b3s__Working_Pattern__c",
"fieldToLoad": ["Name", "b3s__Start__c", "b3s__End__c"],
"apexFilter": "b3s.GroupWorkingPatternLoader"
}
The class implements the packaged global interface FilterPlugin and returns the Set<Id> of records that pass; the server ANDs Id IN :ids into the query as a bind variable (no statement-length limit, pagination unchanged). Key rules:
apexFiltercan stand alone (the Id set is the only filter) or combine withfilterRecordsByclauses.- The class name only ever comes from the CMT — never from the client.
- Name resolution is namespace-aware:
"b3s.GroupShiftLoader"works whether the class is packaged, local, or deployed unpackaged into a subscriber org; subscriber orgs can reference their own bare-named classes. - Plugins run in their own sharing context — a plugin meant for elevated portal guests must be
without sharing, or it returns zero Ids. - Bundled plugins (
GroupShiftLoader,GroupWorkingPatternLoader,GroupLocationLoader) ship unpackaged inunpackaged-once/classes/and power the Group scheduling provider.
Full contract, context map, limits and testing guidance.
6. Batching & large data sets
Loaders are built to return very large sets (100k+ records):
- The server caps each page at 45,000 records, ordered by Id.
- The response carries
hasMore/nextClauseIndex/nextLastId; the client loops until exhausted, running multiplefilterRecordsByclauses in sequence and de-duplicating by Id across clauses. - The object describe travels on the first page only; Date/Datetime/Time values arrive as epoch milliseconds and are normalized client-side.
- Governor footprint: each page is one Apex request. An
apexFilterplugin runs once per page request, so keep plugin queries selective.
7. Operational notes
- Caching — loader metadata can be cached client-side; after editing a loader's JSON, hard-refresh the app to pick up the change.
- Refreshing — apps can re-run a single loader (
DataLoadingEngine.refreshAdditionalData(name)) and can splice in a freshly created record by Id (addRecordToLoader) even if it doesn't yet match the filter; the fetch uses the loader's server-side field list. - Errors — a failing loader surfaces a toast with the loader's
label; boot-time JSON problems fail the metadata call with the offendingDeveloperNamein the message. - Fields must be loaded to be used — anything read by scheduler filters, matching rules or UI templates has to appear in
fieldToLoad(or the app definition's field paths). A missing field is justundefinedon the record, not an error.