|
| 1 | +/** |
| 2 | + * Apply stage — executes a ChangePlan against SAP. |
| 3 | + * |
| 4 | + * Each tier is deployed as its own `AdkObjectSet` so that DDIC objects are |
| 5 | + * saved + activated before the application-code tier starts. Within a tier, |
| 6 | + * objects are saved with `mode: 'upsert'` — SAP's PUT endpoints auto-detect |
| 7 | + * whether the object already exists so we don't need separate create/update |
| 8 | + * paths here. |
| 9 | + * |
| 10 | + * Locking: |
| 11 | + * - Before each tier, an outer `BatchLockSession` pre-acquires locks to |
| 12 | + * catch conflicts early (e.g. another user editing). Because ADK's |
| 13 | + * `save()` also lock/unlocks per object, we release the batch session |
| 14 | + * immediately after pre-flight — the CSRF security session is preserved |
| 15 | + * so the subsequent per-object re-lock is cheap. |
| 16 | + * - If the batch pre-flight fails, the tier is aborted and all locks |
| 17 | + * acquired so far (in that tier) are rolled back; partial state from |
| 18 | + * previous tiers is documented to the caller. |
| 19 | + */ |
| 20 | +import type { AdkObject, AdkContext } from '@abapify/adk'; |
| 21 | +import { AdkObjectSet } from '@abapify/adk'; |
| 22 | +import { |
| 23 | + createBatchLockSession, |
| 24 | + type BatchLockTarget, |
| 25 | +} from '@abapify/adt-locks'; |
| 26 | +import type { ChangePlan, DependencyTier } from './plan'; |
| 27 | + |
| 28 | +export interface ApplyOptions { |
| 29 | + /** Transport request used for lock + save operations. */ |
| 30 | + transport?: string; |
| 31 | + /** Dry run — build plan but don't touch SAP. */ |
| 32 | + dryRun?: boolean; |
| 33 | + /** Activate saved objects after each tier. Default: true. */ |
| 34 | + activate?: boolean; |
| 35 | + /** |
| 36 | + * Force-unlock objects currently locked by the authenticated user before |
| 37 | + * the batch lock session begins. Mirrors `adt export --unlock`. |
| 38 | + */ |
| 39 | + unlock?: boolean; |
| 40 | + /** Per-tier progress hook. */ |
| 41 | + onTierStart?: (tier: DependencyTier, size: number) => void; |
| 42 | + /** Per-object progress hook. */ |
| 43 | + onObject?: (object: AdkObject, status: string) => void; |
| 44 | +} |
| 45 | + |
| 46 | +export interface ApplyTierResult { |
| 47 | + tier: DependencyTier; |
| 48 | + size: number; |
| 49 | + saved: number; |
| 50 | + failed: number; |
| 51 | + unchanged: number; |
| 52 | + activated: number; |
| 53 | + errors: Array<{ name: string; type: string; error: string }>; |
| 54 | +} |
| 55 | + |
| 56 | +export interface ApplyResult { |
| 57 | + /** Per-tier stats in the order they were applied. */ |
| 58 | + tiers: ApplyTierResult[]; |
| 59 | + /** Aggregate totals. */ |
| 60 | + totals: { |
| 61 | + saved: number; |
| 62 | + failed: number; |
| 63 | + unchanged: number; |
| 64 | + activated: number; |
| 65 | + }; |
| 66 | + /** True if the caller aborted before all tiers ran (lock conflict). */ |
| 67 | + aborted: boolean; |
| 68 | +} |
| 69 | + |
| 70 | +/** Execute the change plan. Always returns a result — caller decides exit. */ |
| 71 | +export async function applyPlan( |
| 72 | + plan: ChangePlan, |
| 73 | + ctx: AdkContext, |
| 74 | + options: ApplyOptions = {}, |
| 75 | +): Promise<ApplyResult> { |
| 76 | + const activate = options.activate ?? true; |
| 77 | + const result: ApplyResult = { |
| 78 | + tiers: [], |
| 79 | + totals: { saved: 0, failed: 0, unchanged: 0, activated: 0 }, |
| 80 | + aborted: false, |
| 81 | + }; |
| 82 | + |
| 83 | + for (const group of plan.groups) { |
| 84 | + const tierResult: ApplyTierResult = { |
| 85 | + tier: group.tier, |
| 86 | + size: group.entries.length, |
| 87 | + saved: 0, |
| 88 | + failed: 0, |
| 89 | + unchanged: 0, |
| 90 | + activated: 0, |
| 91 | + errors: [], |
| 92 | + }; |
| 93 | + options.onTierStart?.(group.tier, group.entries.length); |
| 94 | + |
| 95 | + if (options.dryRun) { |
| 96 | + // Pretend everything succeeded for reporting only. |
| 97 | + for (const entry of group.entries) { |
| 98 | + options.onObject?.(entry.object, 'dry-run'); |
| 99 | + tierResult.saved++; |
| 100 | + } |
| 101 | + result.tiers.push(tierResult); |
| 102 | + result.totals.saved += tierResult.saved; |
| 103 | + continue; |
| 104 | + } |
| 105 | + |
| 106 | + // Optional: force-unlock stale locks owned by the current user. |
| 107 | + if (options.unlock && ctx.lockService) { |
| 108 | + for (const entry of group.entries) { |
| 109 | + try { |
| 110 | + await ctx.lockService.forceUnlock(entry.object.objectUri); |
| 111 | + } catch { |
| 112 | + /* not locked or locked by another user — ignore */ |
| 113 | + } |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + // Pre-flight: batch-lock to surface conflicts BEFORE we start mutating. |
| 118 | + // We release immediately afterwards because ADK's save() will re-lock |
| 119 | + // per object within the same security session (CSRF survives unlock). |
| 120 | + let preflightFailed = false; |
| 121 | + if (ctx.lockService) { |
| 122 | + const targets: BatchLockTarget[] = group.entries.map((e) => ({ |
| 123 | + objectUri: e.object.objectUri, |
| 124 | + options: { |
| 125 | + transport: options.transport, |
| 126 | + objectName: e.object.name, |
| 127 | + objectType: e.object.type, |
| 128 | + }, |
| 129 | + })); |
| 130 | + const batch = createBatchLockSession(ctx.lockService, targets); |
| 131 | + try { |
| 132 | + await batch.begin(); |
| 133 | + } catch (err) { |
| 134 | + const msg = err instanceof Error ? err.message : String(err); |
| 135 | + tierResult.errors.push({ |
| 136 | + name: '(batch)', |
| 137 | + type: group.tier, |
| 138 | + error: `batch lock pre-flight failed: ${msg}`, |
| 139 | + }); |
| 140 | + tierResult.failed = group.entries.length; |
| 141 | + preflightFailed = true; |
| 142 | + } finally { |
| 143 | + await batch.release(); |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + if (preflightFailed) { |
| 148 | + result.tiers.push(tierResult); |
| 149 | + result.totals.failed += tierResult.failed; |
| 150 | + result.aborted = true; |
| 151 | + // Stop applying further tiers — the caller sees a partial-apply state. |
| 152 | + break; |
| 153 | + } |
| 154 | + |
| 155 | + // Build the tier's AdkObjectSet and deploy. |
| 156 | + const set = new AdkObjectSet(ctx); |
| 157 | + for (const entry of group.entries) set.add(entry.object); |
| 158 | + |
| 159 | + const deployResult = await set.deploy({ |
| 160 | + transport: options.transport, |
| 161 | + activate, |
| 162 | + mode: 'upsert', |
| 163 | + onProgress: (_processed, _total, current) => { |
| 164 | + options.onObject?.(current, 'saved'); |
| 165 | + }, |
| 166 | + }); |
| 167 | + |
| 168 | + tierResult.saved = deployResult.save.success; |
| 169 | + tierResult.failed = deployResult.save.failed; |
| 170 | + tierResult.unchanged = deployResult.save.unchanged; |
| 171 | + tierResult.activated = deployResult.activation?.success ?? 0; |
| 172 | + for (const r of deployResult.save.results) { |
| 173 | + if (!r.success && !r.unchanged) { |
| 174 | + tierResult.errors.push({ |
| 175 | + name: r.object.name, |
| 176 | + type: r.object.type, |
| 177 | + error: r.error ?? 'unknown error', |
| 178 | + }); |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + result.tiers.push(tierResult); |
| 183 | + result.totals.saved += tierResult.saved; |
| 184 | + result.totals.failed += tierResult.failed; |
| 185 | + result.totals.unchanged += tierResult.unchanged; |
| 186 | + result.totals.activated += tierResult.activated; |
| 187 | + } |
| 188 | + |
| 189 | + return result; |
| 190 | +} |
0 commit comments