@@ -10,7 +10,7 @@ import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
1010import type { ResourceAddress , StateFile } from "../types/state.ts" ;
1111import { addressKey } from "../types/state.ts" ;
1212import { getResourceDeclaration } from "./declaration.ts" ;
13- import { computeResourceHash } from "./hasher.ts" ;
13+ import { computeReplacementFingerprint , computeResourceHash } from "./hasher.ts" ;
1414import { buildReadinessBaseline , classifyReadinessImpact , diffReadinessBaseline } from "./plan-semantics.ts" ;
1515
1616export interface PlanOptions {
@@ -218,9 +218,92 @@ export async function buildPlan(
218218 } ) ;
219219 }
220220
221+ coalesceChannelRenames ( actions , config , state ) ;
221222 return { actions, diagnostics : diagnostics . getAll ( ) } ;
222223}
223224
225+ /**
226+ * A YAML key is a resource address, but changing that key should not force a
227+ * remote Channel replacement when the old and new declarations form one
228+ * unambiguous same-type pair. Retaining the remote id is especially important
229+ * for messaging providers that allow a credential set to belong to only one
230+ * Channel at a time.
231+ */
232+ function coalesceChannelRenames ( actions : PlannedAction [ ] , config : ProjectConfig , state : StateFile ) : void {
233+ const creates = actions . filter ( ( action ) => action . action === "create" && action . address . type === "channel" ) ;
234+ const deletes = actions . filter ( ( action ) => action . action === "delete" && action . address . type === "channel" ) ;
235+ const stateByAddress = new Map ( state . resources . map ( ( resource ) => [ addressKey ( resource . address ) , resource ] ) ) ;
236+ const matchedDeletes = new Set < PlannedAction > ( ) ;
237+
238+ for ( const create of creates ) {
239+ const desiredType = config . channels ?. [ create . address . name ] ?. type ;
240+ if ( ! desiredType ) continue ;
241+ const desiredFingerprint = computeReplacementFingerprint ( create . address , config ) ;
242+ const candidates = deletes . filter ( ( deletion ) => {
243+ if ( matchedDeletes . has ( deletion ) || deletion . address . provider !== create . address . provider ) return false ;
244+ const prior = stateByAddress . get ( addressKey ( deletion . address ) ) ;
245+ const snapshot = prior ?. remote_snapshot as { channel_type ?: unknown } | undefined ;
246+ if ( snapshot ?. channel_type !== desiredType ) return false ;
247+ return ! prior ?. replacement_fingerprint || prior . replacement_fingerprint === desiredFingerprint ;
248+ } ) ;
249+ if ( candidates . length !== 1 ) continue ;
250+
251+ const deletion = candidates [ 0 ] ! ;
252+ const prior = stateByAddress . get ( addressKey ( deletion . address ) ) ;
253+ const competingCreates = creates . filter (
254+ ( candidate ) =>
255+ candidate !== create &&
256+ candidate . address . provider === create . address . provider &&
257+ config . channels ?. [ candidate . address . name ] ?. type === desiredType &&
258+ ( ! prior ?. replacement_fingerprint ||
259+ computeReplacementFingerprint ( candidate . address , config ) === prior . replacement_fingerprint ) ,
260+ ) ;
261+ if ( competingCreates . length > 0 ) continue ;
262+
263+ create . action = "update" ;
264+ create . previousAddress = deletion . address ;
265+ create . before = deletion . before ;
266+ create . driftKind = "local" ;
267+ create . reason = `Channel key renamed from '${ deletion . address . name } ' (remote resource retained)` ;
268+ protectRenamedChannelDependencies ( actions , stateByAddress , deletion , create ) ;
269+ matchedDeletes . add ( deletion ) ;
270+ }
271+
272+ for ( let index = actions . length - 1 ; index >= 0 ; index -- ) {
273+ if ( matchedDeletes . has ( actions [ index ] ! ) ) actions . splice ( index , 1 ) ;
274+ }
275+ }
276+
277+ /** Do not delete the old Identity/Template when the Channel migration that releases it fails. */
278+ function protectRenamedChannelDependencies (
279+ actions : PlannedAction [ ] ,
280+ stateByAddress : Map < string , StateFile [ "resources" ] [ number ] > ,
281+ deletion : PlannedAction ,
282+ replacement : PlannedAction ,
283+ ) : void {
284+ const prior = stateByAddress . get ( addressKey ( deletion . address ) ) ;
285+ const snapshot = prior ?. remote_snapshot as { identity_id ?: unknown ; template_id ?: unknown } | undefined ;
286+ const referencedIds = new Set (
287+ [ snapshot ?. identity_id , snapshot ?. template_id ] . filter ( ( id ) : id is string => typeof id === "string" ) ,
288+ ) ;
289+ if ( referencedIds . size === 0 ) return ;
290+
291+ for ( const action of actions ) {
292+ if (
293+ action . action !== "delete" ||
294+ ( action . address . type !== "identity" && action . address . type !== "template" ) ||
295+ action . address . provider !== replacement . address . provider
296+ ) {
297+ continue ;
298+ }
299+ const dependency = stateByAddress . get ( addressKey ( action . address ) ) ;
300+ if ( ! dependency ?. remote_id || ! referencedIds . has ( dependency . remote_id ) ) continue ;
301+ if ( ! action . dependencies . some ( ( address ) => addressKey ( address ) === addressKey ( replacement . address ) ) ) {
302+ action . dependencies . push ( replacement . address ) ;
303+ }
304+ }
305+ }
306+
224307/** Keep the old delivery resource alive when creating its new materialization fails. */
225308function deliveryReplacementAddress ( address : ResourceAddress , graph : DependencyGraph ) : ResourceAddress | undefined {
226309 if ( address . type !== "agent" && address . type !== "template" ) return undefined ;
0 commit comments