@prorigo/protrak-forge 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,7 +72,7 @@ These are **Protrak-specific gotchas you cannot catch by reading the code**. Alw
72
72
 
73
73
  ### Understanding the workspace:
74
74
  - `list_protrak_schema_elements` (optionally with `kind`: `type`, `attribute`, `lifecycle`,
75
- `relation_type`) catalogs what's defined.
75
+ `relation_type`, `rule`) catalogs what's defined.
76
76
  - `read_protrak_schema_element` (with `kind` and `name`) returns the full JSON of any one
77
77
  element — for `kind='type'` you get the resolved schema with attributes, lifecycle, and
78
78
  relations hydrated.
@@ -124,6 +124,102 @@ These are **Protrak-specific gotchas you cannot catch by reading the code**. Alw
124
124
  - Return `ProgramResult { IsSuccess = false, Message = "..." }` to block operations in
125
125
  Pre-triggers (style rule).
126
126
 
127
+ ## How to write notification templates
128
+
129
+ Notification templates define the email/push content sent by Protrak. They use **Razor syntax**
130
+ (`@Model.*`), not `{{}}` placeholders.
131
+
132
+ ### Writing a new notification template:
133
+ 1. Call `get_protrak_notification_context(type_name?)` — returns existing templates and the full
134
+ `@Model` variable reference for all target types.
135
+ 2. Write the Razor HTML body using `@Model.*` variables.
136
+ 3. Call `generate_protrak_notification_template(name='<Title> Body', target='PromoteNotification', email_html='<Razor HTML>')`.
137
+ 4. Call again for the subject: `generate_protrak_notification_template(name='<Title> Subject', target='PromoteNotification', email_html='<subject line>')`.
138
+
139
+ ```
140
+ get_protrak_notification_context(type_name='Invoice')
141
+ # ... agent writes the Razor HTML ...
142
+ generate_protrak_notification_template(name='Invoice Sent Body', target='PromoteNotification', email_html='<p>Hi @Model.AttributeValues["Manager"].UserValues[0].UserName,...</p>')
143
+ generate_protrak_notification_template(name='Invoice Sent Subject', target='PromoteNotification', email_html='Invoice @Model.InstanceName has been sent')
144
+ ```
145
+
146
+ Each call creates `NotificationTemplates/<name>.json` (metadata) and `NotificationTemplates/<name>.html`
147
+ (email body). `hashCode` is left empty — Protrak recomputes it on import.
148
+
149
+ Valid `target` values: `PromoteNotification`, `AccountNotification`, `WorkflowNotification`,
150
+ `WorkflowTaskNotification`.
151
+
152
+ ## How to write Rules
153
+
154
+ Rules express attribute-filter logic used by display conditions, conditional formatting,
155
+ dependent picklists, and Access Policy. They are file-based: `Rules/<Name>.json`.
156
+
157
+ ### Writing a new rule:
158
+ 1. Call `get_protrak_rule_context(type_name?)` — returns the supported-conditions-per-
159
+ attribute-type table, value-less / range conditions, dynamic-value formats
160
+ (`@Instance.X`, `@User.X`, `$StartOfToday(±Nd)`), date mnemonics, basic-attribute map,
161
+ existing rule names, and canonical examples. Pass `type_name` to include that type's
162
+ resolved attribute schema (rules are not type-bound, but most rules reference one type).
163
+ 2. Translate the natural-language intent into `attribute_filter_groups[]`. Each group has
164
+ an `operator` joining it to the **next** group; each condition has an `operator` joining
165
+ it to the **next** condition in the same group. The final group / condition uses `None`.
166
+ 3. Instance-context `attribute_name` must exist in `Attributes/` or be a basic attribute
167
+ (`Name`, `State`, `Created`, `Creator`, `Modifier`, `Lifecycle`, ...). User-context names
168
+ refer to user-profile attributes (not workspace-validated). For `UserRoles`, omit
169
+ `attribute_name`.
170
+ 4. Call `generate_protrak_rule(name, attribute_filter_groups, error_message?)`. Pass
171
+ `error_message` **only** when the rule will be selected as a Type's Access Policy —
172
+ it appears in the access-denied response.
173
+
174
+ ### Attaching a rule to a layout / type / picklist:
175
+ Call `attach_protrak_rule(usage, rule_name, ...)`. One polymorphic tool covers five shapes:
176
+
177
+ - `usage='display_condition'`, `target_kind='layout_template'`, `target_name`, `widget_id?`
178
+ — appends to the widget's `displayConditions: string[]`.
179
+ - `usage='display_condition'`, `target_kind='form'`, `target_name`, `attribute_name`,
180
+ `container_id?` — sets the **singular** `displayCondition` on the form field.
181
+ - `usage='format_condition'`, `target_name=<ViewLayout>`, `attribute_name`, `section_name?`,
182
+ `widget_name?`, `style`, `order_index?` — appends `{ rule:{name}, orderIndex, style }`
183
+ to the ViewLayout field's `formatConditions`.
184
+ - `usage='conditional_formatting'`, `target_kind='type_widget'|'form'`, `target_name`,
185
+ `attribute_name`, `style`, `order_index?` — appends `{ ruleName, orderIndex, style }`
186
+ to the column/field's `conditionalFormatting`.
187
+ - `usage='access_policy'`, `type_name` — sets `accessPolicyRule` on `Types/<name>.json`.
188
+ - `usage='dependent_picklist'`, `attribute_name=<PicklistAttr>`, `option_name` — sets the
189
+ `rule` field on that picklist option.
190
+
191
+ Style is structured: `{ background_color?: '#RRGGBB', fore_color?: '#RRGGBB',
192
+ font_style?: ('Bold'|'Italic'|'Underline'|'Strikethrough')[] }`. The tool serializes it
193
+ to the comma-terminated CSS-string the SPA evaluator expects (`backgroundColor`, `color`,
194
+ `fontWeight`, `fontStyle`, `textDecorationLine`). Both `Underline` and `Strikethrough`
195
+ collapse into a single `textDecorationLine` token.
196
+
197
+ ```
198
+ get_protrak_rule_context(type_name='ProjectAllocation')
199
+ # ... agent picks attributes, condition, dynamic values ...
200
+ generate_protrak_rule(
201
+ name='OverdueAllocation',
202
+ attribute_filter_groups=[{
203
+ operator: 'None',
204
+ conditions: [{
205
+ attribute_context: 'Instance',
206
+ attribute_name: 'AllocationEndDate',
207
+ condition: 'LessThanOrEqual',
208
+ first_value: { value_type: 'Static', value: '$StartOfToday()' },
209
+ operator: 'None',
210
+ }],
211
+ }],
212
+ )
213
+ attach_protrak_rule(
214
+ usage='conditional_formatting',
215
+ rule_name='OverdueAllocation',
216
+ target_kind='type_widget',
217
+ target_name='ProjectActiveAllocationsRelationWidget',
218
+ attribute_name='AllocationEndDate',
219
+ style={ background_color: '#ee8bb6' },
220
+ )
221
+ ```
222
+
127
223
  ## Project structure
128
224
  - `Types/` — entity type JSON definitions
129
225
  - `Attributes/` — attribute JSON definitions
@@ -131,3 +227,8 @@ These are **Protrak-specific gotchas you cannot catch by reading the code**. Alw
131
227
  - `Programs/` — C# program source + JSON metadata (paired files; use
132
228
  `generate_protrak_program` to keep them in sync)
133
229
  - `RelationTypes/` — relation definitions between types
230
+ - `NotificationTemplates/` — notification template files (paired `.json` + `.html`; use
231
+ `generate_protrak_notification_template` to keep them in sync)
232
+ - `Rules/` — business-rule JSON definitions (attribute filter groups). Use
233
+ `generate_protrak_rule` to author and `attach_protrak_rule` to wire into layouts /
234
+ type access policies / picklist dependencies.
@@ -37,7 +37,6 @@ instance.SetPicklistAttributeValue("Status", "Active");
37
37
  | [Create and Connect Child Instances](./create-and-connect-pattern.md) | Create related instances and link them | PostCreate, PostConnect |
38
38
  | [Cascade Promote](./cascade-promote-pattern.md) | Propagate a lifecycle transition to related instances; aggregate-all-approvers check | PromoteActionCommand, PostCreate, Scheduler |
39
39
  | [Query and Filter](./query-filter-pattern.md) | Query instances using InstanceQuery and RelatedInstanceQuery | All program types |
40
- | [Aggregate Related Instances](./aggregate-related-instances.md) | Parent → related fan-out + roll-up; uses `RelatedQueryBuilder.Select(...)` to avoid N+1 GetInstance calls | Scheduler, PostCreate, PromoteActionCommand |
41
40
  | [Invoke Common Program](./invoke-common-program.md) | Call reusable logic encapsulated in a Common Program | All program types |
42
41
  | [Manage Instance Access](./manage-instance-access-pattern.md) | Grant or revoke per-instance user access; create users; manage roles | PostConnect, PostCreate, PromoteActionCommand |
43
42
  | [Delete Related Data](./delete-related-data-pattern.md) | Delete or unlink child instances before parent deletion | PreDelete |
@@ -94,16 +93,6 @@ Scheduler
94
93
  → [Cascade Promote] — promote when all approvers are done
95
94
  ```
96
95
 
97
- ### Scheduler: Roll up an aggregate from related instances onto the parent
98
-
99
- ```
100
- Scheduler
101
- → [Query and Filter] — pick the parent set (e.g. all Active Projects)
102
- → [Aggregate Related Instances] — for each parent, fetch related rows with
103
- RelatedQueryBuilder.Select(...) in a SINGLE call, sum/count/aggregate, and
104
- write back via InstanceBuilder.ForUpdate + UpdateInstanceAsync.
105
- ```
106
-
107
96
  ---
108
97
 
109
98
  ## Service Injection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prorigo/protrak-forge",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Protrak domain context for coding agents — MCP server for GitHub Copilot, Claude, and Cursor",
5
5
  "bin": {
6
6
  "protrak-forge": "./bin/protrak-forge.js"
@@ -1,89 +0,0 @@
1
- # Attribute Copy / Auto-populate Pattern
2
-
3
- ## When to Use
4
- Use this pattern to automatically copy or compute attribute values when an instance is created or updated. Common uses:
5
- - Copy a field from a parent/related instance
6
- - Auto-fill a date field (e.g. start date = today)
7
- - Compute a derived value from other attributes
8
- - Propagate changes from parent to children
9
-
10
- ## Program Types
11
- Applies to: `PostCreate`, `PostUpdate`, `PreCreate`, `PreUpdate`
12
-
13
- ## Services Used
14
- - `IInstanceService` — to fetch and update instances
15
- - `IQueryBuilderService` — to filter related instances
16
-
17
- ## Code Example: Copy on Create
18
-
19
- ```csharp
20
- using Prorigo.Protrak.API.Contracts;
21
- using System;
22
- using System.Threading.Tasks;
23
-
24
- namespace MyProject.Customization
25
- {
26
- public class AutoPopulateStartDate : IPostCreateTriggerProgramAsync
27
- {
28
- public IInstanceService InstanceService { get; set; }
29
-
30
- public async Task RunAsync(Guid instanceId)
31
- {
32
- var instance = await InstanceService.GetInstanceAsync(
33
- instanceId, new[] { "StartDate" });
34
-
35
- if (instance == null) return;
36
-
37
- instance.Attributes = new[]
38
- {
39
- new Attribute { Name = "StartDate", Value = DateTime.UtcNow.ToString("o") }
40
- };
41
-
42
- await InstanceService.UpdateInstanceAsync(instance, null);
43
- }
44
- }
45
- }
46
- ```
47
-
48
- ## Code Example: Copy from Related Instance
49
-
50
- ```csharp
51
- public class CopyCustomerRegionToProject : IPostCreateTriggerProgramAsync
52
- {
53
- public IInstanceService InstanceService { get; set; }
54
-
55
- public async Task RunAsync(Guid instanceId)
56
- {
57
- var project = await InstanceService.GetInstanceAsync(
58
- instanceId, new[] { "Customer", "Region" });
59
-
60
- if (project == null) return;
61
-
62
- // Get related customer ID from reference attribute
63
- var customerIds = project.Attributes?
64
- .FirstOrDefault(a => a.Name == "Customer")?.ReferenceValues;
65
-
66
- if (customerIds == null || customerIds.Length == 0) return;
67
-
68
- var customer = await InstanceService.GetInstanceAsync(
69
- customerIds[0], new[] { "Region" });
70
-
71
- var region = customer?.Attributes?
72
- .FirstOrDefault(a => a.Name == "Region")?.Value?.ToString();
73
-
74
- if (string.IsNullOrEmpty(region)) return;
75
-
76
- project.Attributes = new[]
77
- {
78
- new Attribute { Name = "Region", Value = region }
79
- };
80
- await InstanceService.UpdateInstanceAsync(project, null);
81
- }
82
- }
83
- ```
84
-
85
- ## Key Rules
86
- - Use `PostCreate`/`PostUpdate` (not `PreCreate`) when you need the instance ID to perform updates
87
- - Null-check `ReferenceValues` before accessing related instances
88
- - Pass `null` for `lastModified` in `UpdateInstanceAsync` to skip optimistic concurrency check
89
- - Only set the attributes you want to change in `instance.Attributes`
@@ -1,72 +0,0 @@
1
- # Batch Update Pattern
2
-
3
- ## When to Use
4
- Use this pattern to update multiple related instances efficiently in parallel. Common uses:
5
- - Update all child instances when a parent changes
6
- - Recalculate a field across all instances of a type
7
- - Cascade attribute changes
8
-
9
- ## Program Types
10
- Applies to: `PostUpdate`, `PromoteActionCommand`, `Scheduler`
11
-
12
- ## Services Used
13
- - `IInstanceService` — to fetch and update instances in bulk
14
- - `IQueryBuilderService` — to filter instances for batch processing
15
-
16
- ## Code Example
17
-
18
- ```csharp
19
- using Prorigo.Protrak.API.Contracts;
20
- using System;
21
- using System.Linq;
22
- using System.Threading.Tasks;
23
-
24
- namespace MyProject.Customization
25
- {
26
- public class UpdateAllTaskPrioritiesOnProjectChange : IPostUpdateTriggerProgramAsync
27
- {
28
- public IInstanceService InstanceService { get; set; }
29
- public IQueryBuilderService QueryBuilderService { get; set; }
30
-
31
- public async Task<ProgramResult> RunAsync(Guid instanceId)
32
- {
33
- var project = await InstanceService.GetInstanceAsync(
34
- instanceId, new[] { "Priority" });
35
-
36
- var priority = project?.Attributes?
37
- .FirstOrDefault(a => a.Name == "Priority")?.Value?.ToString();
38
-
39
- if (string.IsNullOrEmpty(priority))
40
- return new ProgramResult { IsSuccess = true };
41
-
42
- // Find all tasks for this project
43
- var filter = QueryBuilderService.CreateRelatedInstanceFilterExpression(
44
- "Project", instanceId);
45
- var query = new InstanceQuery { TypeName = "Task", Filter = filter, Take = 500 };
46
- var tasks = await InstanceService.GetInstancesAsync(query);
47
-
48
- if (tasks?.Data == null || tasks.Data.Length == 0)
49
- return new ProgramResult { IsSuccess = true };
50
-
51
- // Update all tasks in parallel
52
- var updates = tasks.Data.Select(async task =>
53
- {
54
- task.Attributes = new[]
55
- {
56
- new Attribute { Name = "Priority", Value = priority }
57
- };
58
- await InstanceService.UpdateInstanceAsync(task, null);
59
- });
60
-
61
- await Task.WhenAll(updates);
62
- return new ProgramResult { IsSuccess = true };
63
- }
64
- }
65
- }
66
- ```
67
-
68
- ## Key Rules
69
- - Use `Task.WhenAll` for parallel updates — much faster than sequential `await` in a loop
70
- - Limit batch size with `Take` (max 500 recommended)
71
- - Pass `null` for `lastModified` in `UpdateInstanceAsync` to skip concurrency check
72
- - Only include changed attributes in `instance.Attributes` to avoid overwriting other fields
@@ -1,76 +0,0 @@
1
- # Reference Navigation Pattern
2
-
3
- ## When to Use
4
- Use this pattern to navigate from one instance to a related instance via a reference attribute. Common uses:
5
- - Get the parent project of a task
6
- - Get the customer linked to an order
7
- - Follow a chain of relations (task → project → customer)
8
-
9
- ## Program Types
10
- Applies to: `PreCreate`, `PostCreate`, `PreUpdate`, `PostUpdate`, `PromoteActionCommand`
11
-
12
- ## Services Used
13
- - `IInstanceService` — to fetch related instances by GUID
14
-
15
- ## Code Example
16
-
17
- ```csharp
18
- using Prorigo.Protrak.API.Contracts;
19
- using System;
20
- using System.Linq;
21
- using System.Threading.Tasks;
22
-
23
- namespace MyProject.Customization
24
- {
25
- public class ValidateEquipmentStatus : IPreCreateTriggerProgramAsync
26
- {
27
- public IInstanceService InstanceService { get; set; }
28
-
29
- public async Task<ProgramResult> RunAsync(Instance instance)
30
- {
31
- // Navigate: Process → Equipment (via reference attribute)
32
- var equipmentIds = instance.Attributes?
33
- .FirstOrDefault(a => a.Name == "Equipment")?.ReferenceValues;
34
-
35
- if (equipmentIds == null || equipmentIds.Length == 0)
36
- return new ProgramResult { IsSuccess = false, Message = "Equipment is required." };
37
-
38
- var equipment = await InstanceService.GetInstanceAsync(
39
- equipmentIds[0], new[] { "Status", "AvailableFrom" });
40
-
41
- if (equipment == null)
42
- return new ProgramResult { IsSuccess = false, Message = "Equipment not found." };
43
-
44
- var status = equipment.Attributes?
45
- .FirstOrDefault(a => a.Name == "Status")?.Value?.ToString();
46
-
47
- if (status != "Available")
48
- {
49
- return new ProgramResult
50
- {
51
- IsSuccess = false,
52
- Message = $"Equipment is not available (current status: {status})."
53
- };
54
- }
55
-
56
- return new ProgramResult { IsSuccess = true };
57
- }
58
- }
59
- }
60
- ```
61
-
62
- ## Key Rules
63
- - Reference attributes expose related instance IDs via `ReferenceValues` (array of Guids)
64
- - Always null-check `ReferenceValues` and verify length before accessing index 0
65
- - Fetch only the attributes you need in the `GetInstanceAsync` attributes parameter
66
- - For multi-valued references, iterate or use `ReferenceValues[0]` for the first
67
-
68
- ## When NOT to use this pattern (anti-pattern callout)
69
-
70
- This pattern is for **navigating to a single related instance via a Reference attribute**. It is **not** the right approach when you have many related instances and want to read attributes from each.
71
-
72
- > ❌ **DO NOT** call `GetRelatedInstancesAsync` to get a list of related ids and then call `GetInstanceAsync` once per id to fetch their attributes. That's an N+1 round-trip — one extra DB hit per related instance.
73
- >
74
- > ✅ **DO** use `RelatedQueryBuilder.Select(attr1, attr2, ...)` so the related instances come back **with attributes populated in a single call**. Read the values off the `RelatedInstance` directly with `GetDateAttributeValue`, `GetTextAttributeValue`, etc.
75
-
76
- For collection-level fan-out (sums, counts, rollups), see [Aggregate Related Instances](./aggregate-related-instances.md).
@@ -1,73 +0,0 @@
1
- # Report Program Pattern
2
-
3
- ## When to Use
4
- Use this pattern to generate dynamic reports from Protrak data. Report programs provide both the data and the configuration (columns, charts) for the report UI.
5
-
6
- ## Program Types
7
- Applies to: `Report`
8
-
9
- ## Services Used
10
- - `IInstanceService` — to query data for the report
11
- - `IQueryBuilderService` — to filter data
12
-
13
- ## Code Example
14
-
15
- ```csharp
16
- using Prorigo.Protrak.API.Contracts;
17
- using System;
18
- using System.Collections.Generic;
19
- using System.Linq;
20
- using System.Threading.Tasks;
21
-
22
- namespace MyProject.Customization
23
- {
24
- public class EquipmentUtilizationReport : IReportProgramAsync
25
- {
26
- public IInstanceService InstanceService { get; set; }
27
- public IQueryBuilderService QueryBuilderService { get; set; }
28
-
29
- public async Task<ReportData> GetReportDataAsync(ReportQuery query)
30
- {
31
- var instanceQuery = new InstanceQuery
32
- {
33
- TypeName = "Equipment",
34
- Take = 500
35
- };
36
-
37
- var equipment = await InstanceService.GetInstancesAsync(instanceQuery);
38
-
39
- var rows = equipment?.Data?.Select(e => new ReportRow
40
- {
41
- Values = new Dictionary<string, object>
42
- {
43
- ["Name"] = e.Attributes?.FirstOrDefault(a => a.Name == "Name")?.Value ?? "",
44
- ["Status"] = e.Attributes?.FirstOrDefault(a => a.Name == "Status")?.Value ?? "",
45
- ["UtilizationHours"] = e.Attributes?.FirstOrDefault(a => a.Name == "UtilizationHours")?.Value ?? 0,
46
- }
47
- }).ToList() ?? new List<ReportRow>();
48
-
49
- return new ReportData { Rows = rows };
50
- }
51
-
52
- public async Task<ReportConfig> GetReportConfigAsync()
53
- {
54
- return new ReportConfig
55
- {
56
- Title = "Equipment Utilization",
57
- Columns = new[]
58
- {
59
- new ReportColumn { Name = "Name", DisplayName = "Equipment Name" },
60
- new ReportColumn { Name = "Status", DisplayName = "Status" },
61
- new ReportColumn { Name = "UtilizationHours", DisplayName = "Hours Used" },
62
- }
63
- };
64
- }
65
- }
66
- }
67
- ```
68
-
69
- ## Key Rules
70
- - Implement both `GetReportDataAsync` and `GetReportConfigAsync`
71
- - `ReportQuery` may contain filter parameters set by the user in the UI
72
- - Return an empty `ReportData` (not null) if no data matches
73
- - Keep queries efficient — reports run on demand, avoid slow full-table scans
@@ -1,77 +0,0 @@
1
- # Scheduler Program Pattern
2
-
3
- ## When to Use
4
- Use this pattern for background jobs that run on a schedule (cron). Common uses:
5
- - Send daily reminder notifications
6
- - Auto-close overdue instances
7
- - Compute/recalculate aggregate values from related instances (see [Aggregate Related Instances](./aggregate-related-instances.md) for the canonical roll-up shape)
8
- - Sync data with external systems
9
-
10
- ## Program Types
11
- Applies to: `Scheduler`
12
-
13
- ## Services Used
14
- - `IInstanceService` — to query and update instances
15
- - `INotificationService` — to send scheduled notifications
16
- - `IQueryBuilderService` — to filter instances
17
-
18
- ## Code Example
19
-
20
- ```csharp
21
- using Prorigo.Protrak.API.Contracts;
22
- using System;
23
- using System.Linq;
24
- using System.Threading.Tasks;
25
-
26
- namespace MyProject.Customization
27
- {
28
- public class SendDailyOverdueReminders : ISchedulerProgramAsync
29
- {
30
- public IInstanceService InstanceService { get; set; }
31
- public INotificationService NotificationService { get; set; }
32
- public IQueryBuilderService QueryBuilderService { get; set; }
33
-
34
- public async Task RunAsync()
35
- {
36
- var today = DateTime.UtcNow.Date;
37
- var filter = QueryBuilderService.CreateAttributeFilterExpression(
38
- "DueDate", today.ToString("yyyy-MM-dd"), FilterOperator.LessThanOrEqual);
39
-
40
- var query = new InstanceQuery
41
- {
42
- TypeName = "WorkOrder",
43
- Filter = filter,
44
- StateName = "Open",
45
- Take = 500
46
- };
47
-
48
- var overdueItems = await InstanceService.GetInstancesAsync(query);
49
- if (overdueItems?.Data == null || overdueItems.Data.Length == 0)
50
- return;
51
-
52
- foreach (var item in overdueItems.Data)
53
- {
54
- var assignee = item.Attributes?
55
- .FirstOrDefault(a => a.Name == "AssignedEngineer")?.Value?.ToString();
56
-
57
- if (string.IsNullOrEmpty(assignee)) continue;
58
-
59
- await NotificationService.SendNotificationAsync(
60
- new[] { assignee },
61
- "Overdue Work Order",
62
- $"Work Order {item.TrackingId} is overdue. Please take action.",
63
- item.Id
64
- );
65
- }
66
- }
67
- }
68
- }
69
- ```
70
-
71
- ## Key Rules
72
- - The `Run()` / `RunAsync()` method takes no parameters
73
- - Use `ISchedulerProgramAsync` for async operations
74
- - Avoid long-running loops — use `Take` to limit batch sizes
75
- - Log or notify on errors so failures are visible
76
- - Consider idempotency: what happens if the scheduler runs twice?
77
- - When the scheduler walks parent → related instances and reads attributes off each child, use `RelatedQueryBuilder.Select(...)` in a single `GetRelatedInstancesAsync` call. **Do not** fan out to `GetInstanceAsync` per related id — that's an N+1 anti-pattern. See [Aggregate Related Instances](./aggregate-related-instances.md).