Skip to content

Commit a57f8fb

Browse files
authored
feat: support AiAuthoringBundle metadata with dynamic handler resolution (#1234)
1 parent de82ee4 commit a57f8fb

File tree

9 files changed

+798
-693
lines changed

9 files changed

+798
-693
lines changed

.github/workflows/npm-service.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ jobs:
8989
message: |
9090
Published under `${{ env.DEV_CHANNEL }}` npm channel.
9191
```sh
92-
$ sf plugins install sf-git-merge-driver@${{ env.DEV_CHANNEL }}
92+
$ sf plugins install sfdx-git-delta@${{ env.DEV_CHANNEL }}
9393
```
9494
comment-tag: dev-publish
9595
mode: recreate

DESIGN.md

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ flowchart LR
8181
| `inFolder` | Whether the type uses folder-based organization |
8282
| `content[]` | Sub-types sharing the same directory (Dashboard → DashboardFolder) |
8383
| `xmlTag` + `key` | In-file diff semantics for sub-element types |
84+
| `adapter` | SDR `strategies.adapter` value (e.g. `bundle`, `mixedContent`, `digitalExperience`) — drives dynamic handler resolution |
85+
| `decomposition` | SDR `strategies.decomposition` value (e.g. `folderPerType`) — used for child type handler heuristics |
8486
| `pruneOnly` | Type is only ever added to destructiveChanges, never package |
8587
| `excluded` | Sub-element type is not independently packageable |
8688

@@ -112,22 +114,50 @@ When `--ignore-whitespace` is enabled, `--word-diff-regex` and related flags are
112114

113115
**Entry**: `DiffLineInterpreter.process(lines)` (`src/service/diffLineInterpreter.ts`)
114116

115-
Each diff line is dispatched to a type-specific handler via an async queue capped at `getConcurrencyThreshold()`. The `TypeHandlerFactory` selects the handler class based on the metadata type's `xmlName`.
117+
Each diff line is dispatched to a type-specific handler via an async queue capped at `getConcurrencyThreshold()`. The `TypeHandlerFactory` selects the handler class using a multi-tier resolution chain that combines explicit overrides with dynamic resolution from SDR registry attributes.
116118

117119
### Dispatch Flow
118120

119121
```mermaid
120122
flowchart TD
121123
Line["Diff line<br/>'A force-app/main/.../MyClass.cls'"] --> TF["TypeHandlerFactory"]
122124
TF --> ME["MetadataBoundaryResolver<br/>creates MetadataElement"]
123-
TF --> HM{"xmlName in<br/>handlerMap?"}
124-
HM -->|Yes| SH["Specialized Handler"]
125-
HM -->|No| DH["StandardHandler"]
126-
SH --> C["handler.collect()"]
125+
TF --> T1{"xmlName in<br/>handlerMap?"}
126+
T1 -->|Yes| SH["Explicit Handler Override"]
127+
T1 -->|No| T2{"inFolder?"}
128+
T2 -->|Yes| IF["InFolderHandler"]
129+
T2 -->|No| T3{"adapter in<br/>adapterHandlerMap?"}
130+
T3 -->|Yes| AH["Adapter-Based Handler"]
131+
T3 -->|No| T4{"has parentXmlName?"}
132+
T4 -->|Yes| CH["Child Type Heuristics"]
133+
T4 -->|No| T5{"parent of<br/>InFile children?"}
134+
T5 -->|Yes| IFH["InFileHandler"]
135+
T5 -->|No| DH["StandardHandler"]
136+
CH --> C["handler.collect()"]
137+
SH --> C
138+
IF --> C
139+
AH --> C
140+
IFH --> C
127141
DH --> C
128142
C --> HR["HandlerResult<br/>{manifests, copies, warnings}"]
129143
```
130144

145+
### Handler Resolution Tiers
146+
147+
The `resolveHandler()` method applies these tiers in order, returning the first match:
148+
149+
| Tier | Signal | Handler | Example |
150+
|------|--------|---------|---------|
151+
| 1. Explicit override | `xmlName` in `handlerMap` | Varies | `Flow``FlowHandler` |
152+
| 2. Folder-based | `inFolder: true` | `InFolderHandler` | `Document`, `EmailTemplate` |
153+
| 3. Adapter-based | `adapter` from SDR strategies | `InResourceHandler` / `InBundleHandler` | `bundle``InResource` |
154+
| 4. Child heuristics | `xmlTag` + `key` + non-adapter parent | `DecomposedHandler` | `WorkflowAlert` |
155+
| 4b. Child heuristics | no `xmlTag` + `folderPerType` parent | `CustomObjectChildHandler` | `ListView` |
156+
| 5. InFile parent | has children with `xmlTag`+`key` | `InFileHandler` | `Workflow` |
157+
| 6. Fallback | none of the above | `StandardHandler` | `ApexClass` |
158+
159+
This design means most new SDR metadata types are handled automatically without code changes. Only types requiring specialized behavior need explicit overrides in `handlerMap`.
160+
131161
`MetadataBoundaryResolver` creates a `MetadataElement` — a value object capturing the parsed identity of the diff line: base path, extension, parent folder, component name, and path segments after the type directory. It may scan the git tree to find the component root when the directory name isn't present in the path.
132162

133163
### Handler Hierarchy
@@ -201,7 +231,7 @@ Detects the format by file extension and routes accordingly.
201231
#### InFolderHandler
202232

203233
**Extends**: StandardHandler
204-
**Used by**: Document, EmailTemplate
234+
**Used by**: Document, EmailTemplate (and any type with `inFolder: true` not explicitly overridden)
205235

206236
Handles metadata stored in named folders. When a file changes, the handler also copies the folder's `-meta.xml` descriptor and any companion files sharing the same base name (e.g. thumbnails). Element names use the `Folder/MemberName` format.
207237

@@ -229,7 +259,7 @@ Extends shared folder behavior: changing any sub-file also forces inclusion of t
229259
#### InResourceHandler
230260

231261
**Extends**: StandardHandler
232-
**Used by**: ExperienceBundle, GenAiPlannerBundle, LightningTypeBundle, StaticResource, WaveTemplateBundle
262+
**Used by**: ExperienceBundle, GenAiPlannerBundle, LightningTypeBundle, StaticResource, WaveTemplateBundle (and any type with `adapter: "bundle"` or `adapter: "mixedContent"` not explicitly overridden)
233263

234264
Handles bundle-like resources where changing any file within the bundle triggers the entire bundle to be redeployed. On deletion, checks if the bundle root still has content — if yes, treats as modification instead of deletion (the bundle still exists with remaining files).
235265

@@ -257,7 +287,7 @@ Field translation files are not independently deployable. The handler produces a
257287
#### DecomposedHandler
258288

259289
**Extends**: StandardHandler
260-
**Used by**: SharingCriteriaRule, SharingGuestRule, SharingOwnerRule, Territory2, Territory2Rule, WorkflowAlert, WorkflowFieldUpdate, WorkflowFlowAction, WorkflowKnowledgePublish, WorkflowOutboundMessage, WorkflowRule, WorkflowSend, WorkflowTask
290+
**Used by**: SharingCriteriaRule, SharingGuestRule, SharingOwnerRule, Territory2, Territory2Rule, WorkflowAlert, WorkflowFieldUpdate, WorkflowFlowAction, WorkflowKnowledgePublish, WorkflowOutboundMessage, WorkflowRule, WorkflowSend, WorkflowTask (and any child type with `xmlTag` + `key` whose parent has no adapter)
261291

262292
Handles metadata stored as individual files in sub-folders of a parent type. Element names are qualified as `ParentName.ChildName`. On addition, also copies the parent type's `-meta.xml`.
263293

@@ -285,7 +315,7 @@ On addition, scans the object's `fields/` subfolder for Master Detail fields and
285315
#### CustomObjectChildHandler
286316

287317
**Extends**: StandardHandler
288-
**Used by**: BusinessProcess, CompactLayout, FieldSet, Index, ListView, RecordType, SharingReason, ValidationRule, WebLink
318+
**Used by**: BusinessProcess, CompactLayout, FieldSet, Index, ListView, RecordType, SharingReason, ValidationRule, WebLink (and any child type without `xmlTag` whose parent has `decomposition: "folderPerType"`)
289319

290320
Handles child types living in CustomObject sub-folders. Element names are qualified as `ObjectName.ChildName`.
291321

@@ -416,7 +446,7 @@ Logger.debug(lazy`result: ${() => JSON.stringify(largeObject)}`)
416446

417447
| Extension point | How to extend |
418448
|-----------------|---------------|
419-
| New metadata type handler | Add entry to `handlerMap` in `TypeHandlerFactory` mapping `xmlName → HandlerClass` |
449+
| New metadata type handler | Most types are auto-resolved via SDR registry attributes (`adapter`, `decomposition`, `inFolder`, `xmlTag`+`key`). Only add an explicit entry to `handlerMap` in `TypeHandlerFactory` when a type needs behavior that differs from what SDR signals would select. |
420450
| New post-processor | Add a `BaseProcessor` subclass to `registeredProcessors` in `postProcessorManager.ts` |
421451
| Metadata type override | Add definition to `internalRegistry.ts` with special flags (`pruneOnly`, `excluded`, `xmlTag`, etc.) |
422452
| Programmatic API | `import sgd from 'sfdx-git-delta'` — call `await sgd(config)` directly, receiving the `Work` object |
@@ -474,5 +504,7 @@ Metadata type definition (Zod-validated):
474504
| `inFolder` | `boolean` | Folder-based organization |
475505
| `content` | `Metadata[]` | Sub-types sharing the directory |
476506
| `xmlTag` + `key` | `string` | In-file diff semantics |
507+
| `adapter` | `string` | SDR `strategies.adapter` — drives handler auto-resolution |
508+
| `decomposition` | `string` | SDR `strategies.decomposition` — child type heuristics |
477509
| `pruneOnly` | `boolean` | Only in destructiveChanges |
478510
| `excluded` | `boolean` | Not independently packageable |

__tests__/unit/lib/metadata/sdrMetadataAdapter.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe('SDRMetadataAdapter', () => {
6969
// Assert
7070
const apexClass = metadata.find(m => m.xmlName === 'ApexClass')
7171
expect(apexClass).toEqual({
72+
adapter: 'default',
7273
directoryName: 'classes',
7374
inFolder: false,
7475
metaFile: false,
@@ -77,6 +78,57 @@ describe('SDRMetadataAdapter', () => {
7778
})
7879
})
7980

81+
it('Given SDR type with strategies, When converting, Then stores adapter and decomposition', () => {
82+
// Arrange
83+
const mockRegistry: MockRegistry = {
84+
types: {
85+
customobject: {
86+
id: 'customobject',
87+
name: 'CustomObject',
88+
directoryName: 'objects',
89+
suffix: 'object',
90+
strategies: {
91+
adapter: 'decomposed',
92+
decomposition: 'folderPerType',
93+
transformer: 'decomposed',
94+
},
95+
},
96+
},
97+
}
98+
const adapter = new SDRMetadataAdapter(mockRegistry as never)
99+
100+
// Act
101+
const metadata = adapter.toInternalMetadata()
102+
103+
// Assert
104+
const sut = metadata.find(m => m.xmlName === 'CustomObject')
105+
expect(sut?.adapter).toBe('decomposed')
106+
expect(sut?.decomposition).toBe('folderPerType')
107+
})
108+
109+
it('Given SDR type without strategies, When converting, Then adapter and decomposition are absent', () => {
110+
// Arrange
111+
const mockRegistry: MockRegistry = {
112+
types: {
113+
flow: {
114+
id: 'flow',
115+
name: 'Flow',
116+
directoryName: 'flows',
117+
suffix: 'flow',
118+
},
119+
},
120+
}
121+
const adapter = new SDRMetadataAdapter(mockRegistry as never)
122+
123+
// Act
124+
const metadata = adapter.toInternalMetadata()
125+
126+
// Assert
127+
const sut = metadata.find(m => m.xmlName === 'Flow')
128+
expect(sut?.adapter).toBeUndefined()
129+
expect(sut?.decomposition).toBeUndefined()
130+
})
131+
80132
it('Given SDR type with bundle adapter, When converting, Then metaFile is true', () => {
81133
// Arrange
82134
const mockRegistry: MockRegistry = {

__tests__/unit/lib/service/typeHandlerFactory.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import CustomField from '../../../../src/service/customFieldHandler'
88
import CustomObjectChildHandler from '../../../../src/service/customObjectChildHandler'
99
import Decomposed from '../../../../src/service/decomposedHandler'
1010
import FlowHandler from '../../../../src/service/flowHandler'
11+
import InBundleHandler from '../../../../src/service/inBundleHandler'
12+
import InFileHandler from '../../../../src/service/inFileHandler'
1113
import InFolder from '../../../../src/service/inFolderHandler'
1214
import InResource from '../../../../src/service/inResourceHandler'
1315
import ReportingFolderHandler from '../../../../src/service/reportingFolderHandler'
@@ -99,4 +101,68 @@ describe('the type handler factory', () => {
99101
await typeHandlerFactory.getTypeHandler(`Z ${line}`)
100102
).toBeInstanceOf(Standard)
101103
})
104+
105+
it('Given deletion change type, When resolving handler, Then uses from revision', async () => {
106+
const sut = await typeHandlerFactory.getTypeHandler(
107+
`D force-app/main/default/classes/folder/file`
108+
)
109+
expect(sut).toBeInstanceOf(Standard)
110+
})
111+
112+
describe('dynamic resolution', () => {
113+
describe('adapter-based resolution', () => {
114+
it('Given bundle adapter type, When resolving handler, Then returns InResource', async () => {
115+
const sut = await typeHandlerFactory.getTypeHandler(
116+
`Z force-app/main/default/aiAuthoringBundles/MyBundle/file.txt`
117+
)
118+
expect(sut).toBeInstanceOf(InResource)
119+
})
120+
121+
it('Given mixedContent adapter type, When resolving handler, Then returns InResource', async () => {
122+
const sut = await typeHandlerFactory.getTypeHandler(
123+
`Z force-app/main/default/staticresources/MyResource/file.txt`
124+
)
125+
expect(sut).toBeInstanceOf(InResource)
126+
})
127+
128+
it('Given digitalExperience adapter type, When resolving handler, Then returns InBundle', async () => {
129+
const sut = await typeHandlerFactory.getTypeHandler(
130+
`Z force-app/main/default/digitalExperiences/site/home/file.json`
131+
)
132+
expect(sut).toBeInstanceOf(InBundleHandler)
133+
})
134+
})
135+
136+
describe('child type resolution', () => {
137+
it('Given child with xmlTag and key of non-decomposed parent, When resolving handler, Then returns Decomposed', async () => {
138+
const sut = await typeHandlerFactory.getTypeHandler(
139+
`Z force-app/main/default/workflows/Account/workflowAlerts/MyAlert.workflowAlert-meta.xml`
140+
)
141+
expect(sut).toBeInstanceOf(Decomposed)
142+
})
143+
144+
it('Given child without xmlTag of folderPerType parent, When resolving handler, Then returns CustomObjectChildHandler', async () => {
145+
const sut = await typeHandlerFactory.getTypeHandler(
146+
`Z force-app/main/default/objects/Account/listViews/MyView.listView-meta.xml`
147+
)
148+
expect(sut).toBeInstanceOf(CustomObjectChildHandler)
149+
})
150+
151+
it('Given child with parentXmlName not matching any child heuristic, When resolving handler, Then returns Standard', async () => {
152+
const sut = await typeHandlerFactory.getTypeHandler(
153+
`Z force-app/main/default/workSkillRoutingAttributes/MyRouting/MyAttribute.workSkillRoutingAttribute-meta.xml`
154+
)
155+
expect(sut).toBeInstanceOf(Standard)
156+
})
157+
})
158+
159+
describe('InFile parent resolution', () => {
160+
it('Given parent type with children having xmlTag, When resolving handler, Then returns InFile', async () => {
161+
const sut = await typeHandlerFactory.getTypeHandler(
162+
`Z force-app/main/default/workflows/Account.workflow-meta.xml`
163+
)
164+
expect(sut).toBeInstanceOf(InFileHandler)
165+
})
166+
})
167+
})
102168
})

0 commit comments

Comments
 (0)