diff --git a/src/core/listing/apply.ts b/src/core/listing/apply.ts index bdb7d626..b61bb566 100644 --- a/src/core/listing/apply.ts +++ b/src/core/listing/apply.ts @@ -1,5 +1,6 @@ import type { AndroidLocaleInfo, AppleLocaleInfo, StoreConfig } from '../store/storeConfig.js'; import type { DraftListing, ListingBrief, LocaleDraft } from '../types/listing.js'; +import type { MutableDeep } from '../types/mutable.js'; /** * App Store field limits, in characters. `keywords` is the limit on the *comma-joined* string (Apple * counts the serialized field, and `storeConfig` joins with `", "`), not the count of keywords. @@ -23,11 +24,11 @@ const clampText = (listingText: string, maxCharacters: number): string => { return listingText.slice(0, maxCharacters).trimEnd(); }; /** The comma-joined serialization Apple counts against the 100-char keyword limit. */ -export const serializeKeywords = (keywords: string[]): string => { +export const serializeKeywords = (keywords: readonly string[]): string => { return keywords.join(', '); }; /** Keep keywords from the front until adding the next would overflow the joined-string limit. */ -const clampKeywords = (keywords: string[], maxCharacters: number): string[] => { +const clampKeywords = (keywords: readonly string[], maxCharacters: number): string[] => { const keptKeywords: string[] = []; for (const keyword of keywords) { if (serializeKeywords([...keptKeywords, keyword]).length > maxCharacters) break; @@ -47,7 +48,7 @@ export const clampDraft = ( warnings: string[]; } => { const warningMessages: string[] = []; - const clampedDraft: DraftListing = {}; + const clampedDraft: MutableDeep = {}; /** Clamp one optional text field, recording a warning when it was over the limit. */ const fitText = ( listingText: string | undefined, @@ -98,7 +99,7 @@ export const briefFor = ( currentListing: AppleLocaleInfo | undefined, aboutOverride: string | undefined, ): ListingBrief => { - const listingBrief: ListingBrief = { locale: localeName, appName: displayName }; + const listingBrief: MutableDeep = { locale: localeName, appName: displayName }; let aboutText = aboutOverride; if (aboutText === undefined && currentListing !== undefined) aboutText = currentListing.promotionalText; @@ -135,6 +136,38 @@ export const deriveAndroidLocale = (listingDraft: DraftListing): AndroidLocaleIn * fields (so untouched fields and other locales survive), per targeted platform. The App Store fields * map 1:1; the Play fields are derived via {@link deriveAndroidLocale}. Returns a new config. */ +/** Merge a draft over one locale's existing App Store listing, copying keywords into a mutable array. */ +const mergeAppleLocale = ( + existingLocale: AppleLocaleInfo | undefined, + listingDraft: DraftListing, +): AppleLocaleInfo => { + const mergedLocale: MutableDeep = {}; + if (existingLocale !== undefined) { + if (existingLocale.title !== undefined) mergedLocale.title = existingLocale.title; + if (existingLocale.subtitle !== undefined) mergedLocale.subtitle = existingLocale.subtitle; + if (existingLocale.description !== undefined) + mergedLocale.description = existingLocale.description; + if (existingLocale.keywords !== undefined) mergedLocale.keywords = [...existingLocale.keywords]; + if (existingLocale.releaseNotes !== undefined) + mergedLocale.releaseNotes = existingLocale.releaseNotes; + if (existingLocale.promotionalText !== undefined) + mergedLocale.promotionalText = existingLocale.promotionalText; + if (existingLocale.marketingUrl !== undefined) + mergedLocale.marketingUrl = existingLocale.marketingUrl; + if (existingLocale.supportUrl !== undefined) + mergedLocale.supportUrl = existingLocale.supportUrl; + if (existingLocale.privacyPolicyUrl !== undefined) + mergedLocale.privacyPolicyUrl = existingLocale.privacyPolicyUrl; + } + if (listingDraft.title !== undefined) mergedLocale.title = listingDraft.title; + if (listingDraft.subtitle !== undefined) mergedLocale.subtitle = listingDraft.subtitle; + if (listingDraft.description !== undefined) mergedLocale.description = listingDraft.description; + if (listingDraft.promotionalText !== undefined) + mergedLocale.promotionalText = listingDraft.promotionalText; + if (listingDraft.keywords !== undefined) mergedLocale.keywords = [...listingDraft.keywords]; + return mergedLocale; +}; + export const applyDraft = ( storeConfiguration: StoreConfig, localeName: string, @@ -148,12 +181,11 @@ export const applyDraft = ( if (listingTargets.ios) { let appleListing = storeConfiguration.apple; if (appleListing === undefined) appleListing = { info: {} }; + const localeInfo: Record = { ...appleListing.info }; + localeInfo[localeName] = mergeAppleLocale(appleListing.info[localeName], listingDraft); updatedStoreConfiguration.apple = { ...appleListing, - info: { - ...appleListing.info, - [localeName]: { ...appleListing.info[localeName], ...listingDraft }, - }, + info: localeInfo, }; } if (listingTargets.android) { diff --git a/src/core/listing/generator.ts b/src/core/listing/generator.ts index b587da3a..84650725 100644 --- a/src/core/listing/generator.ts +++ b/src/core/listing/generator.ts @@ -3,6 +3,7 @@ import { Data, Effect, Redacted, Schema } from 'effect'; import { LaunchEnvironment, type LaunchEnvironmentService } from '../services/environment.js'; import type { DraftListing, ListingBrief, ListingGenerator } from '../types/listing.js'; import { APPLE_LIMITS, serializeKeywords } from './apply.js'; +import type { MutableDeep } from '../types/mutable.js'; const GeneratedListingSchema = Schema.Struct({ title: Schema.optionalWith(Schema.String, { exact: true }), @@ -138,7 +139,7 @@ export const parseDraftListing = ( stripJsonFence(completionText), ).pipe( Effect.map((generatedListing) => { - const listingDraft: DraftListing = {}; + const listingDraft: MutableDeep = {}; const title = normalizeGeneratedText(generatedListing.title); if (title !== undefined) listingDraft.title = title; const subtitle = normalizeGeneratedText(generatedListing.subtitle); diff --git a/src/core/privacy/parse.ts b/src/core/privacy/parse.ts index d578d206..ab7786ea 100644 --- a/src/core/privacy/parse.ts +++ b/src/core/privacy/parse.ts @@ -12,7 +12,7 @@ const CollectedDataTypeSchema = Schema.Struct({ const USAGE_DESCRIPTION_RE = /(NS\w*UsageDescription)<\/key>\s*(?:([^<]*)<\/string>|)/g; /** De-duplicate while preserving first-seen order. */ -const unique = (strings: string[]): string[] => { +const unique = (strings: readonly string[]): string[] => { return [...new Set(strings)]; }; /** Collect every `...` inside an XML fragment. */ diff --git a/src/core/privacy/reconcile.ts b/src/core/privacy/reconcile.ts index 73e2e920..959b6ff5 100644 --- a/src/core/privacy/reconcile.ts +++ b/src/core/privacy/reconcile.ts @@ -138,7 +138,7 @@ export const reconcilePrivacy = (app: string, surface: PrivacySurface): PrivacyF */ export const buildPrivacyReport = ( findings: PrivacyFinding[], - scanned: string[], + scanned: readonly string[], ): PrivacyReport => { let exitCode: PrivacyReport['exitCode'] = READINESS_EXIT.ok; if (findings.some((finding) => finding.severity === 'blocker')) exitCode = READINESS_EXIT.blocker; diff --git a/src/core/store/accessibility.ts b/src/core/store/accessibility.ts index 3bc77906..39f825a9 100644 --- a/src/core/store/accessibility.ts +++ b/src/core/store/accessibility.ts @@ -9,6 +9,7 @@ import { import { appRecordMissing, plan, type ReconcileContext } from './reconcile.js'; import { errorMessage } from '../services/errorMessage.js'; import type { PlannedAction } from '../types/reconcile.js'; +import type { MutableDeep } from '../types/mutable.js'; import { decodeStoreSurfaceConfig, loadStoreSurfaceConfig, @@ -126,7 +127,7 @@ const supportEquals = ( }; /** Expand omitted accessibility flags to `false`. */ const normalizeSupport = (support: AccessibilitySupport): AccessibilitySupport => { - const normalizedSupport: AccessibilitySupport = {}; + const normalizedSupport: MutableDeep = {}; for (const key of ACCESSIBILITY_SUPPORT_KEYS) normalizedSupport[key] = support[key] === true; return normalizedSupport; }; diff --git a/src/core/store/appEvents.ts b/src/core/store/appEvents.ts index 7b9eb73b..4b69ddc8 100644 --- a/src/core/store/appEvents.ts +++ b/src/core/store/appEvents.ts @@ -1,4 +1,5 @@ import { Data, Effect } from 'effect'; +import type { MutableDeep } from '../types/mutable.js'; import type { AppEventLocalizationInput, AppEventLocalizationResource, @@ -191,7 +192,7 @@ export const createEvent = ( appEventsStore.getAppId(bundleId), ); if (appId === null) return yield* Effect.fail(missingAppRecord(bundleId)); - const eventAttributes: NewAppEvent = { referenceName }; + const eventAttributes: MutableDeep = { referenceName }; if (badge !== undefined) eventAttributes.badge = badge; if (eventRequest.primaryLocale !== undefined) { eventAttributes.primaryLocale = eventRequest.primaryLocale.trim(); @@ -224,7 +225,7 @@ export const localizeEvent = ( }), ); } - const localizationAttributes: AppEventLocalizationInput = {}; + const localizationAttributes: MutableDeep = {}; if (localizationRequest.name !== undefined) { localizationAttributes.name = localizationRequest.name; } diff --git a/src/core/store/ascScreenshots.ts b/src/core/store/ascScreenshots.ts index 9c9900ff..e98e11cf 100644 --- a/src/core/store/ascScreenshots.ts +++ b/src/core/store/ascScreenshots.ts @@ -64,7 +64,7 @@ export type ScreenshotReconcileInput = { }; /** Group members by a derived string key. */ const groupBy = ( - members: Member[], + members: readonly Member[], keyOf: (member: Member) => string, ): Map => { const groups = new Map(); @@ -122,7 +122,7 @@ const reconcileAppScreenshots = ( api: ScreenshotsApi, log: ActionLog, appId: string, - screenshots: LocalScreenshot[], + screenshots: readonly LocalScreenshot[], ): Effect.Effect => Effect.gen(function* () { const versionId = yield* api.getEditableVersionId(appId); @@ -178,7 +178,7 @@ const reconcileScreenshotSet = ( existingSet: ScreenshotSetResource | undefined, displayType: string, locale: string, - screenshots: LocalScreenshot[], + screenshots: readonly LocalScreenshot[], ): Effect.Effect => Effect.gen(function* () { const label = appleDisplayTypeLabel(displayType); @@ -227,7 +227,7 @@ const reconcileSubscriptionReviewScreenshots = ( api: ScreenshotsApi, log: ActionLog, appId: string, - reviewScreenshots: SubscriptionReviewScreenshot[], + reviewScreenshots: readonly SubscriptionReviewScreenshot[], ): Effect.Effect => Effect.gen(function* () { const subscriptionIdByProduct = new Map(); @@ -365,7 +365,7 @@ const reconcilePreviewSet = ( existingSet: PreviewSetResource | undefined, previewType: string, locale: string, - previews: LocalPreview[], + previews: readonly LocalPreview[], ): Effect.Effect => Effect.gen(function* () { const label = applePreviewTypeLabel(previewType); diff --git a/src/core/store/ascSync.ts b/src/core/store/ascSync.ts index e0b3ad0c..858ebe88 100644 --- a/src/core/store/ascSync.ts +++ b/src/core/store/ascSync.ts @@ -300,7 +300,7 @@ export const reconcileApp = ( const catalogContext = emptyCatalogContext(api, input.dryRun, input.allowDestructive); const appId = yield* requireAppStoreRecordId(api, input.bundleId); yield* reconcileCapabilities(catalogContext, input.bundleId, input.capabilities); - let desiredInAppPurchases: InAppPurchaseConfig[] = []; + let desiredInAppPurchases: readonly InAppPurchaseConfig[] = []; if (input.products.inAppPurchases !== undefined) { desiredInAppPurchases = input.products.inAppPurchases; } @@ -372,7 +372,7 @@ const reconcileCapabilities = ( const reconcileInAppPurchases = ( catalogContext: CatalogReconcileContext, appId: string, - desiredPurchases: InAppPurchaseConfig[], + desiredPurchases: readonly InAppPurchaseConfig[], ): Effect.Effect => Effect.gen(function* () { if (desiredPurchases.length === 0) return; diff --git a/src/core/store/availability.ts b/src/core/store/availability.ts index 0edf34f9..a8b7256d 100644 --- a/src/core/store/availability.ts +++ b/src/core/store/availability.ts @@ -68,7 +68,7 @@ export type AvailabilityReconcileInput = { dryRun: boolean; }; /** Uppercase, trim, and de-duplicate a list of territory codes into a stable set. */ -const normalizeTerritories = (territories: string[]): Set => { +const normalizeTerritories = (territories: readonly string[]): Set => { return new Set(territories.map((code) => code.trim().toUpperCase())); }; /** Sorted difference `a \ b` - codes in `a` not in `b`. */ diff --git a/src/core/store/gameCenter.ts b/src/core/store/gameCenter.ts index 8f158709..72879b01 100644 --- a/src/core/store/gameCenter.ts +++ b/src/core/store/gameCenter.ts @@ -17,6 +17,7 @@ import type { GameCenterConfig, LeaderboardConfig, } from '../types/storeSurface.js'; +import type { MutableDeep } from '../types/mutable.js'; import { decodeStoreSurfaceConfig, loadStoreSurfaceConfig, @@ -199,7 +200,7 @@ const localizationLocale = (locale: string | undefined): string => { * Connect) rather than failed. */ const applyLocalization = ( - localizationAction: PlannedAction, + localizationAction: MutableDeep, versionId: string | null, vendorIdentifier: string, writeLocalization: (confirmedVersionId: string) => Effect.Effect, @@ -287,7 +288,7 @@ const reconcileAchievements = ( reconcileContext: ReconcileContext, api: AscGameCenterApi, detail: NonNullable, - declaredAchievements: AchievementConfig[], + declaredAchievements: readonly AchievementConfig[], ): Effect.Effect => Effect.gen(function* () { let existingIdentifiers = new Set(); @@ -333,7 +334,7 @@ const reconcileLeaderboards = ( reconcileContext: ReconcileContext, api: AscGameCenterApi, detail: NonNullable, - declaredLeaderboards: LeaderboardConfig[], + declaredLeaderboards: readonly LeaderboardConfig[], ): Effect.Effect => Effect.gen(function* () { let existingIdentifiers = new Set(); @@ -395,9 +396,9 @@ export const reconcileGameCenter = ( ); return { bundleId: reconcileInput.bundleId, actions: reconcileContext.actions }; } - let achievements: AchievementConfig[] = []; + let achievements: readonly AchievementConfig[] = []; if (gameCenterConfig.achievements !== undefined) achievements = gameCenterConfig.achievements; - let leaderboards: LeaderboardConfig[] = []; + let leaderboards: readonly LeaderboardConfig[] = []; if (gameCenterConfig.leaderboards !== undefined) leaderboards = gameCenterConfig.leaderboards; yield* reconcileAchievements(reconcileContext, api, detail, achievements); yield* reconcileLeaderboards(reconcileContext, api, detail, leaderboards); diff --git a/src/core/store/offers.test.ts b/src/core/store/offers.test.ts index 97fdcec0..cbea3c93 100644 --- a/src/core/store/offers.test.ts +++ b/src/core/store/offers.test.ts @@ -39,7 +39,7 @@ class FakeOffersApi implements AscOffersApi { readonly createdWinBack: WinBackOfferCreate[] = []; readonly createdPromoted: PromotedPurchaseCreate[] = []; introCreateCount = 0; - reorderedTo: string[] | null = null; + reorderedTo: readonly string[] | null = null; getAppId(): Effect.Effect { return Effect.succeed(this.appId); } @@ -115,7 +115,7 @@ class FakeOffersApi implements AscOffersApi { visibleForAllUsers: input.visibleForAllUsers, }); } - reorderPromotedPurchases(_appId: string, orderedIds: string[]): Effect.Effect { + reorderPromotedPurchases(_appId: string, orderedIds: readonly string[]): Effect.Effect { this.reorderedTo = orderedIds; return Effect.void; } diff --git a/src/core/store/offers.ts b/src/core/store/offers.ts index 205cf220..fd8569ca 100644 --- a/src/core/store/offers.ts +++ b/src/core/store/offers.ts @@ -26,6 +26,7 @@ import type { SubscriptionConfig, WinBackOfferConfig, } from '../types/catalog.js'; +import type { MutableDeep } from '../types/mutable.js'; import { errorMessage } from '../services/errorMessage.js'; import { act, makeAppRecordFailure, plan, skip, type ReconcileContext } from './reconcile.js'; @@ -88,7 +89,7 @@ const makeOfferPricePointFailure = Data.tagged('OfferPri const resolvePrices = ( api: AscOffersApi, subscriptionId: string, - prices: OfferPrice[], + prices: readonly OfferPrice[], ): Effect.Effect => Effect.gen(function* () { const resolved: ResolvedOfferPrice[] = []; @@ -116,7 +117,10 @@ const resolvePrices = ( * Validate a price-bearing offer (offer code, promotional, win-back) at the boundary: `FREE_TRIAL` must * carry no prices; any other mode needs at least one. Returns a human reason when invalid, else null. */ -const priceModeError = (offerMode: string, prices: OfferPrice[] | undefined): string | null => { +const priceModeError = ( + offerMode: string, + prices: readonly OfferPrice[] | undefined, +): string | null => { let priceCount = 0; if (prices !== undefined) priceCount = prices.length; if (offerMode === 'FREE_TRIAL') { @@ -132,12 +136,12 @@ const createWithResolvedPrices = ( offersContext: OffersContext, subscriptionId: string, description: string, - prices: OfferPrice[] | undefined, + prices: readonly OfferPrice[] | undefined, write: (resolvedPrices: ResolvedOfferPrice[]) => Effect.Effect, ): Effect.Effect => act(offersContext, description, () => Effect.gen(function* () { - let offerPrices: OfferPrice[] = []; + let offerPrices: readonly OfferPrice[] = []; if (prices !== undefined) offerPrices = prices; const resolvedPrices = yield* resolvePrices(offersContext.api, subscriptionId, offerPrices); yield* write(resolvedPrices); @@ -149,7 +153,7 @@ const reconcileOfferCodes = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: OfferCodeConfig[], + desired: readonly OfferCodeConfig[], ): Effect.Effect => Effect.gen(function* () { const codes = yield* offersContext.api.listSubscriptionOfferCodes(subscriptionId); @@ -188,7 +192,7 @@ const reconcilePromotionalOffers = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: PromotionalOfferConfig[], + desired: readonly PromotionalOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const offers = yield* offersContext.api.listPromotionalOffers(subscriptionId); @@ -229,7 +233,7 @@ const reconcileIntroductoryOffers = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: IntroductoryOfferConfig[], + desired: readonly IntroductoryOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const introductoryOffers = yield* offersContext.api.listIntroductoryOffers(subscriptionId); @@ -267,7 +271,7 @@ const reconcileIntroductoryOffers = ( ]); if (resolvedPrices[0] !== undefined) resolvedPrice = resolvedPrices[0]; } - const introductoryOffer: IntroductoryOfferCreate = { + const introductoryOffer: MutableDeep = { subscriptionId, duration: offer.duration, offerMode: offer.offerMode, @@ -288,7 +292,7 @@ const reconcileWinBackOffers = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: WinBackOfferConfig[], + desired: readonly WinBackOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const winBackOffers = yield* offersContext.api.listWinBackOffers(subscriptionId); @@ -318,7 +322,7 @@ const reconcileWinBackOffers = ( (resolvedPrices) => { let priority: WinBackOfferCreate['priority'] = 'NORMAL'; if (offer.priority !== undefined) priority = offer.priority; - const create: WinBackOfferCreate = { + const create: MutableDeep = { subscriptionId, offerId: offer.offerId, referenceName: offer.referenceName, @@ -422,7 +426,7 @@ const reconcilePromotedPurchases = ( declaredOrder.push(existingPromotionId); continue; } - const create: PromotedPurchaseCreate = { + const create: MutableDeep = { appId, visibleForAllUsers: true, enabled: true, @@ -513,17 +517,17 @@ export const reconcileOffers = ( ); continue; } - let offerCodes: OfferCodeConfig[] = []; + let offerCodes: readonly OfferCodeConfig[] = []; if (subscription.offerCodes !== undefined) offerCodes = subscription.offerCodes; - let promotionalOffers: PromotionalOfferConfig[] = []; + let promotionalOffers: readonly PromotionalOfferConfig[] = []; if (subscription.promotionalOffers !== undefined) { promotionalOffers = subscription.promotionalOffers; } - let introductoryOffers: IntroductoryOfferConfig[] = []; + let introductoryOffers: readonly IntroductoryOfferConfig[] = []; if (subscription.introductoryOffers !== undefined) { introductoryOffers = subscription.introductoryOffers; } - let winBackOffers: WinBackOfferConfig[] = []; + let winBackOffers: readonly WinBackOfferConfig[] = []; if (subscription.winBackOffers !== undefined) { winBackOffers = subscription.winBackOffers; } diff --git a/src/core/store/playProducts.ts b/src/core/store/playProducts.ts index 76c1d160..9df0b501 100644 --- a/src/core/store/playProducts.ts +++ b/src/core/store/playProducts.ts @@ -4,6 +4,7 @@ import type { InAppPurchaseConfig, PlayPriceConfig } from '../types/catalog.js'; import type { PlannedAction } from '../types/reconcile.js'; import { plan, type ReconcileContext } from './reconcile.js'; import { errorMessage } from '../services/errorMessage.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Play's purchase type for a one-off managed (non-subscription) product. */ const MANAGED_PRODUCT = 'managedUser'; /** Status Launch publishes products as - declaring a `play` override means "this product should be sellable". */ @@ -85,7 +86,7 @@ export const toPlayProduct = ( prices[region] = toMoney(price); } } - const desiredProduct: InAppProductResource = { + const desiredProduct: MutableDeep = { sku, status: ACTIVE_STATUS, purchaseType: MANAGED_PRODUCT, diff --git a/src/core/store/playSubscriptions.ts b/src/core/store/playSubscriptions.ts index 68a52b76..24ae40e9 100644 --- a/src/core/store/playSubscriptions.ts +++ b/src/core/store/playSubscriptions.ts @@ -14,6 +14,7 @@ import type { SubscriptionConfig, SubscriptionPeriod, } from '../types/catalog.js'; +import type { MutableDeep } from '../types/mutable.js'; import type { PlannedAction } from '../types/reconcile.js'; import { plan, type ReconcileContext } from './reconcile.js'; import { errorMessage } from '../services/errorMessage.js'; @@ -110,7 +111,9 @@ export const unitsToMicros = (money: PlayMoneyUnits): string => { }; /** Map shared localizations to Play subscription listings (Play requires a description; fall back to title). */ -const listingsFromLocalizations = (localizations: ProductLocalization[]): SubscriptionListing[] => { +const listingsFromLocalizations = ( + localizations: readonly ProductLocalization[], +): SubscriptionListing[] => { return localizations.map((localization) => { let description = localization.name; if (localization.description !== undefined) description = localization.description; @@ -124,8 +127,8 @@ const listingsFromLocalizations = (localizations: ProductLocalization[]): Subscr /** Whether every desired listing has a title/description-equal counterpart already live. */ const listingsInSync = ( - existing: SubscriptionListing[], - desired: SubscriptionListing[], + existing: readonly SubscriptionListing[], + desired: readonly SubscriptionListing[], ): boolean => { const byLanguage = new Map(existing.map((listing) => [listing.languageCode, listing])); return desired.every((listing) => { @@ -137,8 +140,8 @@ const listingsInSync = ( /** Merge desired listings over live ones by language so a patch never drops locales Launch does not manage. */ const mergeListings = ( - existing: SubscriptionListing[], - desired: SubscriptionListing[], + existing: readonly SubscriptionListing[], + desired: readonly SubscriptionListing[], ): SubscriptionListing[] => { const byLanguage = new Map(existing.map((listing) => [listing.languageCode, listing])); for (const listing of desired) { @@ -168,14 +171,14 @@ const basePlanFromConfig = ( /** Re-encode a live base plan for a patch that only appends a new one - drop output-only `state`. */ const resendableBasePlan = (basePlan: BasePlan): BasePlan => { - const resendablePlan: BasePlan = { basePlanId: basePlan.basePlanId }; + const resendablePlan: MutableDeep = { basePlanId: basePlan.basePlanId }; if (basePlan.autoRenewingBasePlanType !== undefined) { resendablePlan.autoRenewingBasePlanType = basePlan.autoRenewingBasePlanType; } if (basePlan.regionalConfigs !== undefined) { - resendablePlan.regionalConfigs = basePlan.regionalConfigs; + resendablePlan.regionalConfigs = [...basePlan.regionalConfigs]; } - if (basePlan.offerTags !== undefined) resendablePlan.offerTags = basePlan.offerTags; + if (basePlan.offerTags !== undefined) resendablePlan.offerTags = [...basePlan.offerTags]; return resendablePlan; }; @@ -195,7 +198,7 @@ const makePlayOfferConfigFailure = Data.tagged('PlayOffe export const offerFromConfig = ( productId: string, basePlanId: string, - basePlanRegions: string[], + basePlanRegions: readonly string[], config: PlaySubscriptionOfferConfig, ): Effect.Effect => { const phases: SubscriptionOfferPhase[] = []; @@ -259,8 +262,8 @@ const offersFromConfigs = ( reconcileContext: ReconcileContext, productId: string, basePlanId: string, - basePlanRegions: string[], - configs: PlaySubscriptionOfferConfig[], + basePlanRegions: readonly string[], + configs: readonly PlaySubscriptionOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const offers: SubscriptionOfferResource[] = []; @@ -294,10 +297,10 @@ const offersFromConfigs = ( type DesiredSubscription = { productId: string; basePlanId: string; - listings: SubscriptionListing[]; + listings: readonly SubscriptionListing[]; basePlan: BasePlan; - basePlanRegions: string[]; - offerConfigs: PlaySubscriptionOfferConfig[]; + basePlanRegions: readonly string[]; + offerConfigs: readonly PlaySubscriptionOfferConfig[]; }; /** Project one catalog subscription into the Play shape Launch will create or patch. */ @@ -310,7 +313,7 @@ const desiredSubscriptionFromConfig = ( if (playOverrides.productId !== undefined) productId = playOverrides.productId; let basePlanId = PERIOD_ISO[subscription.subscriptionPeriod].toLowerCase(); if (playOverrides.basePlanId !== undefined) basePlanId = playOverrides.basePlanId; - let offerConfigs: PlaySubscriptionOfferConfig[] = []; + let offerConfigs: readonly PlaySubscriptionOfferConfig[] = []; if (playOverrides.offers !== undefined) offerConfigs = playOverrides.offers; return { productId, @@ -427,7 +430,7 @@ const reconcileExistingSubscription = ( desired: DesiredSubscription, ): Effect.Effect => Effect.gen(function* () { - let existingListings: SubscriptionListing[] = []; + let existingListings: readonly SubscriptionListing[] = []; if (existing.listings !== undefined) existingListings = existing.listings; if (!listingsInSync(existingListings, desired.listings)) { const mergedListings = mergeListings(existingListings, desired.listings); @@ -444,7 +447,7 @@ const reconcileExistingSubscription = ( } } - let existingBasePlans: BasePlan[] = []; + let existingBasePlans: readonly BasePlan[] = []; if (existing.basePlans !== undefined) existingBasePlans = existing.basePlans; const liveBasePlan = existingBasePlans.find( (basePlan) => basePlan.basePlanId === desired.basePlanId, diff --git a/src/core/store/playTracks.ts b/src/core/store/playTracks.ts index 0dc1ac85..475efaf7 100644 --- a/src/core/store/playTracks.ts +++ b/src/core/store/playTracks.ts @@ -1,5 +1,6 @@ import { Data, Effect, Schema } from 'effect'; import type { PlayRelease } from '../types/googlePlay.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Play release statuses accepted by the Android Publisher API. */ export const RELEASE_STATUSES = ['draft', 'inProgress', 'halted', 'completed'] as const; @@ -105,7 +106,7 @@ export const buildRelease = ( }), ); } - const playRelease: PlayRelease = { + const playRelease: MutableDeep = { status: releaseInput.status, versionCodes: [...releaseInput.versionCodes], }; diff --git a/src/core/store/reconcile.ts b/src/core/store/reconcile.ts index 8930c96b..5fead184 100644 --- a/src/core/store/reconcile.ts +++ b/src/core/store/reconcile.ts @@ -1,10 +1,11 @@ import { Data, Effect } from 'effect'; import { errorMessage } from '../services/errorMessage.js'; import type { PlannedAction } from '../types/reconcile.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Mutable state for one reconciliation pass. */ export type ReconcileContext = { - actions: PlannedAction[]; + actions: MutableDeep[]; dryRun: boolean; }; @@ -23,7 +24,11 @@ export const act = ( description: string, runAction: () => Effect.Effect, ): Effect.Effect => { - const plannedAction: PlannedAction = { description, destructive: false, status: 'planned' }; + const plannedAction: MutableDeep = { + description, + destructive: false, + status: 'planned', + }; reconcileContext.actions.push(plannedAction); if (reconcileContext.dryRun) return Effect.void; return runAction().pipe( @@ -40,8 +45,15 @@ export const act = ( }; /** Record a planned action and return its mutable status handle. */ -export const plan = (reconcileContext: ReconcileContext, description: string): PlannedAction => { - const plannedAction: PlannedAction = { description, destructive: false, status: 'planned' }; +export const plan = ( + reconcileContext: ReconcileContext, + description: string, +): MutableDeep => { + const plannedAction: MutableDeep = { + description, + destructive: false, + status: 'planned', + }; reconcileContext.actions.push(plannedAction); return plannedAction; }; @@ -72,7 +84,7 @@ export const skip = (reconcileContext: ReconcileContext, description: string): v }; /** Tally a reconcile report's action statuses for the run-summary footer (applied / failed / skipped). */ export const summarize = ( - actions: PlannedAction[], + actions: readonly PlannedAction[], ): { applied: number; failed: number; diff --git a/src/core/store/reportsCommand.ts b/src/core/store/reportsCommand.ts index 1585b5c6..79147e63 100644 --- a/src/core/store/reportsCommand.ts +++ b/src/core/store/reportsCommand.ts @@ -14,6 +14,7 @@ import { parseTsv, } from './reports.js'; import { resolveStoreBundleId, type StoreAppSelectionRequirements } from './selectStoreApp.js'; +import type { MutableDeep } from '../types/mutable.js'; const SalesReportsCommandInputSchema = Schema.Struct({ operation: Schema.Literal('sales'), @@ -244,7 +245,7 @@ const downloadSalesReports = ( yield* Effect.forEach( reportDates, (reportDate) => { - const reportQuery: SalesReportQuery = { + const reportQuery: MutableDeep = { vendorNumber, frequency: commandInput.frequency, reportType: commandInput.reportType, @@ -282,7 +283,7 @@ const downloadFinanceReport = ( const vendorNumber = yield* selectVendorNumber(commandInput.vendorNumber); const outputDirectory = yield* projectOutputDirectory(commandInput.out); const appleStore = yield* loadActiveAppleStore(); - const reportQuery: FinanceReportQuery = { + const reportQuery: MutableDeep = { vendorNumber, reportDate: commandInput.date, regionCode: commandInput.region, diff --git a/src/core/store/syncJobs.ts b/src/core/store/syncJobs.ts index 0efc5d74..60b45db0 100644 --- a/src/core/store/syncJobs.ts +++ b/src/core/store/syncJobs.ts @@ -87,9 +87,9 @@ export const makeAppSelectionFailure = Data.tagged('AppSele /** Resolve discovered apps from an optional comma-separated selector. */ export const selectApps = ( - apps: AppDescriptor[], + apps: readonly AppDescriptor[], selector: string | undefined, -): Effect.Effect => { +): Effect.Effect => { if (selector === undefined) return Effect.succeed(apps); if (selector === '') return Effect.succeed(apps); const selectedNames = selector @@ -113,7 +113,7 @@ export const selectApps = ( }); }; /** Build the job list, dropping apps with no iOS bundle id and nothing (capabilities, products, listing, or assets) to sync. */ -export const buildJobs = (apps: AppDescriptor[], config: LaunchConfig) => +export const buildJobs = (apps: readonly AppDescriptor[], config: LaunchConfig) => Effect.gen(function* () { const jobs: SyncJob[] = []; for (const app of apps) { diff --git a/src/core/store/syncRun.ts b/src/core/store/syncRun.ts index f0ce27f4..85fda8e7 100644 --- a/src/core/store/syncRun.ts +++ b/src/core/store/syncRun.ts @@ -38,7 +38,7 @@ export type SyncAppReport = { app: string; bundleId: string; error?: string; - actions?: PlannedAction[]; + actions?: readonly PlannedAction[]; summary?: { applied: number; failed: number; @@ -187,8 +187,14 @@ export const reconcileJob = ( }; if (job.listing) reconcileInput.listing = job.listing; const report = yield* reconcileApp(client, reconcileInput); - report.actions.push(...(yield* reconcileAssetActions(client, job, dryRun, allowDestructive))); - return { job, report }; + const assetActions = yield* reconcileAssetActions(client, job, dryRun, allowDestructive); + return { + job, + report: { + ...report, + actions: [...report.actions, ...assetActions], + }, + }; }).pipe( Effect.catchAll((failure) => Effect.succeed({ diff --git a/src/core/store/team.ts b/src/core/store/team.ts index b1195976..a11bd2c9 100644 --- a/src/core/store/team.ts +++ b/src/core/store/team.ts @@ -81,7 +81,7 @@ export const getTeam = (teamClient: AscTeamApi): Effect.Effect teamFailure('list', cause))); /** Normalize comma-separated role fragments into Apple's canonical role names. */ -const normalizeRoles = (declaredRoles: string[]): string[] => { +const normalizeRoles = (declaredRoles: readonly string[]): string[] => { const normalizedRoles = new Set(); for (const declaredRole of declaredRoles) { const normalizedRole = declaredRole.trim().toUpperCase(); diff --git a/src/core/store/walletIds.ts b/src/core/store/walletIds.ts index f9f37752..0425b362 100644 --- a/src/core/store/walletIds.ts +++ b/src/core/store/walletIds.ts @@ -68,7 +68,7 @@ const reconcileFamily = ( reconcileContext: ReconcileContext, label: string, existing: Set, - declared: WalletIdConfig[], + declared: readonly WalletIdConfig[], create: (identifier: string, name: string) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () {