diff --git a/src/core/adopt/capabilities.ts b/src/core/adopt/capabilities.ts index 6a3aef39..4c6adce5 100644 --- a/src/core/adopt/capabilities.ts +++ b/src/core/adopt/capabilities.ts @@ -144,7 +144,7 @@ export const capabilitiesAdopter: Adopter = { let profileEntitlements: Record | null = null; if (profileContent !== null) profileEntitlements = yield* extractProfileEntitlements(profileContent); - const settingsByType: Record = {}; + const settingsByType: Record = {}; for (const capability of capabilities) { if (capability.settings !== undefined) settingsByType[capability.capabilityType] = capability.settings; diff --git a/src/core/adopt/certs.ts b/src/core/adopt/certs.ts index f2fe7d47..5ba23d7d 100644 --- a/src/core/adopt/certs.ts +++ b/src/core/adopt/certs.ts @@ -84,7 +84,7 @@ export const certsAdopter: Adopter = { Effect.gen(function* () { const bundleResource = yield* appleCatalog.findBundleId(target.bundleId); const certificates = yield* appleCatalog.listDistributionCertificates(); - let profiles: ProfileResource[] = []; + let profiles: readonly ProfileResource[] = []; if (bundleResource !== null) profiles = yield* appleCatalog.listProfilesForBundleId(bundleResource.id); const storedCredentials = yield* describeStoredCredentials(target.keyId); diff --git a/src/core/adopt/configWriter.ts b/src/core/adopt/configWriter.ts index 32e8420f..3000a2d3 100644 --- a/src/core/adopt/configWriter.ts +++ b/src/core/adopt/configWriter.ts @@ -17,10 +17,12 @@ export const aggregateProductPieces = (pieces: readonly ProductPiece[]): AppProd } subscriptionGroups.push(piece.group); } - const products: AppProducts = {}; - if (inAppPurchases.length > 0) products.inAppPurchases = inAppPurchases; - if (subscriptionGroups.length > 0) products.subscriptionGroups = subscriptionGroups; - return products; + if (inAppPurchases.length > 0 && subscriptionGroups.length > 0) { + return { inAppPurchases, subscriptionGroups }; + } + if (inAppPurchases.length > 0) return { inAppPurchases }; + if (subscriptionGroups.length > 0) return { subscriptionGroups }; + return {}; }; /** Serialize a products block keyed by bundle id as an indented, paste-ready TypeScript section. */ diff --git a/src/core/adopt/products.ts b/src/core/adopt/products.ts index da219f32..80b35553 100644 --- a/src/core/adopt/products.ts +++ b/src/core/adopt/products.ts @@ -13,6 +13,7 @@ import type { SubscriptionGroupConfig, SubscriptionPeriod, } from '../types/catalog.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Narrow an App Store in-app purchase type to the modeled config union. */ const parseInAppPurchaseType = (purchaseType: string): InAppPurchaseType | null => { @@ -48,7 +49,7 @@ const productLocalizationsFromResources = ( localizations: readonly LocalizationResource[], ): ProductLocalization[] => localizations.map((localization) => { - const productLocalization: ProductLocalization = { + const productLocalization: MutableDeep = { locale: localization.locale, name: localization.name, }; diff --git a/src/core/doctor/command.ts b/src/core/doctor/command.ts index 6e4a301f..d061a273 100644 --- a/src/core/doctor/command.ts +++ b/src/core/doctor/command.ts @@ -130,7 +130,7 @@ const reconcileDoctorExportCompliance = ( /** Best-effort export-compliance repair for selected iOS apps. */ const fixExportCompliance = ( - selectedApps: AppDescriptor[], + selectedApps: readonly AppDescriptor[], ): Effect.Effect => Effect.gen(function* () { const resolveAppleStore = createAscClientResolver(); diff --git a/src/core/migrate/command.ts b/src/core/migrate/command.ts index 14543124..393039d9 100644 --- a/src/core/migrate/command.ts +++ b/src/core/migrate/command.ts @@ -49,7 +49,7 @@ const readMigration = ( const printMigrationNotes = ( logger: Logger, - notes: MigrationNote[], + notes: readonly MigrationNote[], ): Effect.Effect => Effect.gen(function* () { for (const migrationNote of notes) { diff --git a/src/core/migrate/eas.test.ts b/src/core/migrate/eas.test.ts index b8afdb5e..8d3b80e5 100644 --- a/src/core/migrate/eas.test.ts +++ b/src/core/migrate/eas.test.ts @@ -59,14 +59,14 @@ const app = (over: Partial = {}): AppDescriptor => ({ }); /** The artifact at `path`, asserting it was emitted. */ -const artifact = (artifacts: MigrationArtifact[], path: string): MigrationArtifact => { +const artifact = (artifacts: readonly MigrationArtifact[], path: string): MigrationArtifact => { const found = artifacts.find((entry) => entry.path === path); expect(found, `expected artifact ${path}`).toBeDefined(); return expectDefined(found, `artifact ${path}`); }; /** Notes at a given level. */ -const notesAt = (notes: MigrationNote[], level: MigrationNoteLevel): MigrationNote[] => +const notesAt = (notes: readonly MigrationNote[], level: MigrationNoteLevel): MigrationNote[] => notes.filter((note) => note.level === level); const sampleEasConfiguration = (): EasJson => Effect.runSync(parseEasJson(SAMPLE_EAS)); diff --git a/src/core/migrate/eas.ts b/src/core/migrate/eas.ts index 3cb1595f..26cd09f9 100644 --- a/src/core/migrate/eas.ts +++ b/src/core/migrate/eas.ts @@ -13,6 +13,7 @@ import type { MigrationNote, MigrationResult, } from '../types/migrate.js'; +import type { MutableDeep } from '../types/mutable.js'; import { buildEnvExample, scaffoldStoreConfig } from './scaffold.js'; export type EasMigrationFailure = Readonly<{ @@ -116,7 +117,7 @@ const EasSubmitProfilesSchema = Schema.transform( for (const [profileName, unknownProfile] of Object.entries(unknownProfiles)) { const decodedProfile = Schema.decodeUnknownOption(EasSubmitProfileSchema)(unknownProfile); if (Option.isNone(decodedProfile)) continue; - const submitProfile: EasSubmitProfile = {}; + const submitProfile: MutableDeep = {}; const iosSubmission = decodedProfile.value.ios; if (iosSubmission !== undefined && hasIosSubmitFields(iosSubmission)) { submitProfile.ios = iosSubmission; @@ -375,7 +376,7 @@ type CredentialsDocument = Schema.Schema.Type; export const credentialsSummaryFromDocument = ( credentialsDocument: CredentialsDocument, ): CredentialsSummary | null => { - const credentialsSummary: CredentialsSummary = {}; + const credentialsSummary: MutableDeep = {}; const iosCredentials = credentialsDocument.ios; if (iosCredentials !== undefined) { let distributionCertificatePath: string | undefined; diff --git a/src/core/migrate/fastlane.test.ts b/src/core/migrate/fastlane.test.ts index 87a0b837..552e711d 100644 --- a/src/core/migrate/fastlane.test.ts +++ b/src/core/migrate/fastlane.test.ts @@ -75,7 +75,7 @@ const app = (overrides: Partial = {}): AppDescriptor => { }; /** The artifact at `path`, asserting it was emitted. */ const artifact = ( - migrationArtifacts: MigrationArtifact[], + migrationArtifacts: readonly MigrationArtifact[], artifactPath: string, ): MigrationArtifact => { const matchingArtifact = migrationArtifacts.find((entry) => entry.path === artifactPath); @@ -83,7 +83,10 @@ const artifact = ( return expectDefined(matchingArtifact, `artifact ${artifactPath}`); }; /** Notes at a given level. */ -const notesAt = (migrationNotes: MigrationNote[], level: MigrationNoteLevel): MigrationNote[] => { +const notesAt = ( + migrationNotes: readonly MigrationNote[], + level: MigrationNoteLevel, +): MigrationNote[] => { return migrationNotes.filter((note) => note.level === level); }; const runReadFastlaneSetup = (workingDirectory: string) => @@ -149,7 +152,7 @@ describe('parseFastfile', () => { expect(beta).toBeDefined(); if (beta === undefined) return; expect(beta.platform).toBe('ios'); - expect(beta.actions.sort()).toEqual(['gym', 'match', 'pilot']); + expect([...beta.actions].sort()).toEqual(['gym', 'match', 'pilot']); expect(laneLaunchCommands(beta)).toEqual(['launch build', 'launch release --track testing']); const play = lanes.find((lane) => lane.name === 'play'); expect(play).toBeDefined(); diff --git a/src/core/migrate/fastlane.ts b/src/core/migrate/fastlane.ts index f6dce342..96ff4e46 100644 --- a/src/core/migrate/fastlane.ts +++ b/src/core/migrate/fastlane.ts @@ -20,6 +20,7 @@ import type { MigrationResult, SupplyfileData, } from '../types/migrate.js'; +import type { MutableDeep } from '../types/mutable.js'; import { buildEnvExample, scaffoldStoreConfig } from './scaffold.js'; export type FastlaneMigrationFailure = Readonly<{ @@ -45,7 +46,7 @@ export const readRubyString = (rubySource: string, directiveName: string): strin }; export const parseAppfile = (appfileSource: string): AppfileData => { - const appfile: AppfileData = {}; + const appfile: MutableDeep = {}; const appIdentifier = readRubyString(appfileSource, 'app_identifier'); if (appIdentifier !== undefined) appfile.appIdentifier = appIdentifier; const appleId = readRubyString(appfileSource, 'apple_id'); @@ -60,7 +61,7 @@ export const parseAppfile = (appfileSource: string): AppfileData => { }; export const parseMatchfile = (matchfileSource: string): MatchfileData => { - const matchfile: MatchfileData = {}; + const matchfile: MutableDeep = {}; const gitUrl = readRubyString(matchfileSource, 'git_url'); if (gitUrl !== undefined) matchfile.gitUrl = gitUrl; const signingType = readRubyString(matchfileSource, 'type'); @@ -73,7 +74,7 @@ export const parseMatchfile = (matchfileSource: string): MatchfileData => { }; export const parseSupplyfile = (supplyfileSource: string): SupplyfileData => { - const supplyfile: SupplyfileData = {}; + const supplyfile: MutableDeep = {}; const packageName = readRubyString(supplyfileSource, 'package_name'); if (packageName !== undefined) supplyfile.packageName = packageName; const jsonKey = readRubyString(supplyfileSource, 'json_key'); @@ -189,7 +190,7 @@ export const parseFastfile = ( containsAction(laneSource, actionName), ); const lanePlatform = findLanePlatform(fastfileSource, laneStartIndex); - const fastlaneLane: FastlaneLane = { name: laneName, actions: laneActions }; + const fastlaneLane: MutableDeep = { name: laneName, actions: laneActions }; if (lanePlatform !== undefined) fastlaneLane.platform = lanePlatform; fastlaneLanes.push(fastlaneLane); } @@ -274,21 +275,39 @@ export const readFastlaneSetup = ( if (fastlaneSources.fastfile !== undefined) { parsedFastfile = parseFastfile(fastlaneSources.fastfile); } + let appfile: AppfileData | undefined; + if (fastlaneSources.appfile !== undefined) { + appfile = parseAppfile(fastlaneSources.appfile); + } + let matchfile: MatchfileData | undefined; + if (fastlaneSources.matchfile !== undefined) { + matchfile = parseMatchfile(fastlaneSources.matchfile); + } + let supply: SupplyfileData | undefined; + if (fastlaneSources.supplyfile !== undefined) { + supply = parseSupplyfile(fastlaneSources.supplyfile); + } const fastlaneSetup: FastlaneSetup = { lanes: parsedFastfile.lanes, actions: parsedFastfile.actions, hasDeliverfile: fastlaneSources.deliverfile !== undefined, envKeys: fastlaneSources.environmentKeys, }; - if (fastlaneSources.appfile !== undefined) { - fastlaneSetup.appfile = parseAppfile(fastlaneSources.appfile); + if (appfile !== undefined && matchfile !== undefined && supply !== undefined) { + return { ...fastlaneSetup, appfile, matchfile, supply }; } - if (fastlaneSources.matchfile !== undefined) { - fastlaneSetup.matchfile = parseMatchfile(fastlaneSources.matchfile); + if (appfile !== undefined && matchfile !== undefined) { + return { ...fastlaneSetup, appfile, matchfile }; } - if (fastlaneSources.supplyfile !== undefined) { - fastlaneSetup.supply = parseSupplyfile(fastlaneSources.supplyfile); + if (appfile !== undefined && supply !== undefined) { + return { ...fastlaneSetup, appfile, supply }; + } + if (matchfile !== undefined && supply !== undefined) { + return { ...fastlaneSetup, matchfile, supply }; } + if (appfile !== undefined) return { ...fastlaneSetup, appfile }; + if (matchfile !== undefined) return { ...fastlaneSetup, matchfile }; + if (supply !== undefined) return { ...fastlaneSetup, supply }; return fastlaneSetup; }); @@ -303,7 +322,7 @@ export const laneLaunchCommands = (fastlaneLane: FastlaneLane): string[] => { return launchCommands; }; -const laneNotes = (fastlaneLanes: FastlaneLane[]): MigrationNote[] => { +const laneNotes = (fastlaneLanes: readonly FastlaneLane[]): MigrationNote[] => { const migrationNotes: MigrationNote[] = []; const customLaneNames: string[] = []; for (const fastlaneLane of fastlaneLanes) { diff --git a/src/core/migrate/scaffold.ts b/src/core/migrate/scaffold.ts index 1832df89..738e6fa5 100644 --- a/src/core/migrate/scaffold.ts +++ b/src/core/migrate/scaffold.ts @@ -53,7 +53,7 @@ export const scaffoldStoreConfig = ( * `.env.example` body from imported env KEYS only (values dropped; may be secrets). * Falls back to the starter template when no keys were found. Shared by EAS and Fastlane. */ -export const buildEnvExample = (keys: string[]): string => { +export const buildEnvExample = (keys: readonly string[]): string => { if (keys.length === 0) return ENV_EXAMPLE_TEMPLATE; const header = ENV_EXAMPLE_TEMPLATE.split('\n') .filter((line) => line.startsWith('#')) diff --git a/src/core/plan/orchestrator.test.ts b/src/core/plan/orchestrator.test.ts index 0ed9a4af..49299d01 100644 --- a/src/core/plan/orchestrator.test.ts +++ b/src/core/plan/orchestrator.test.ts @@ -37,7 +37,7 @@ const planner = (plan: SurfacePlan): SurfacePlanner => { /** Execute the planner orchestrator at the test boundary. */ const runPlannerSet = ( planContext: PlanContext, - planners: SurfacePlanner[], + planners: readonly SurfacePlanner[], options: Parameters[2], ) => Effect.runPromise( diff --git a/src/core/plan/orchestrator.ts b/src/core/plan/orchestrator.ts index 5dd9bb6e..75301b1a 100644 --- a/src/core/plan/orchestrator.ts +++ b/src/core/plan/orchestrator.ts @@ -115,7 +115,7 @@ export const planExitCode = ({ */ export const runPlanners = ( planContext: PlanContext, - planners: SurfacePlanner[], + planners: readonly SurfacePlanner[], options: PlanRunOptions, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/plan/planners/appStoreSurface.ts b/src/core/plan/planners/appStoreSurface.ts index 1c6b2058..7f2217fa 100644 --- a/src/core/plan/planners/appStoreSurface.ts +++ b/src/core/plan/planners/appStoreSurface.ts @@ -28,7 +28,7 @@ export type AppStoreSurfaceSpec = { config: TConfig, ) => Effect.Effect< { - actions: PlannedAction[]; + actions: readonly PlannedAction[]; }, unknown >; @@ -111,7 +111,10 @@ export type TeamSurfaceSpec = { | TConfig | Effect.Effect | undefined; - reconcile: (api: AscSurfacesApi, config: TConfig) => Effect.Effect; + reconcile: ( + api: AscSurfacesApi, + config: TConfig, + ) => Effect.Effect; }; /** * Plan one team-level App Store surface: omit when nothing is declared, skip with a hint when no Apple diff --git a/src/core/plan/planners/euDistribution.ts b/src/core/plan/planners/euDistribution.ts index f737e5fb..5c0bba8d 100644 --- a/src/core/plan/planners/euDistribution.ts +++ b/src/core/plan/planners/euDistribution.ts @@ -5,6 +5,7 @@ import { } from '@core/store/euDistribution.js'; import { planTeamSurface } from './appStoreSurface.js'; import type { SurfacePlanner } from '@core/types/plan.js'; +import type { EuDistributionConfig } from '@core/types/storeSurface.js'; /** Surface id - also the value users pass as `launch plan eu-distribution`. */ const SURFACE = 'eu-distribution'; export const euDistributionPlanner: SurfacePlanner = { @@ -15,7 +16,7 @@ export const euDistributionPlanner: SurfacePlanner = { surface: SURFACE, direction: 'additive', config: () => - resolveSidecarConfig({ + resolveSidecarConfig({ typed: planContext.config.euDistribution, configPath: 'eu-distribution.config.json', explicitPath: false, diff --git a/src/core/plan/planners/gameCenter.ts b/src/core/plan/planners/gameCenter.ts index 22aaf278..e33a13a1 100644 --- a/src/core/plan/planners/gameCenter.ts +++ b/src/core/plan/planners/gameCenter.ts @@ -2,6 +2,7 @@ import { resolveSidecarConfig } from '@core/config/config.js'; import { loadGameCenterConfig, reconcileGameCenter } from '@core/store/gameCenter.js'; import { planAppStoreSurface } from './appStoreSurface.js'; import type { SurfacePlanner } from '@core/types/plan.js'; +import type { GameCenterConfig } from '@core/types/storeSurface.js'; /** Surface id - also the value users pass as `launch plan game-center`. */ const SURFACE = 'game-center'; export const gameCenterPlanner: SurfacePlanner = { @@ -12,7 +13,7 @@ export const gameCenterPlanner: SurfacePlanner = { surface: SURFACE, direction: 'additive', configFor: (bundleId) => - resolveSidecarConfig({ + resolveSidecarConfig({ typed: planContext.config.gameCenter?.[bundleId], configPath: 'gamecenter.config.json', explicitPath: false, diff --git a/src/core/plan/planners/playProducts.ts b/src/core/plan/planners/playProducts.ts index 4f504ec0..0c0ad04d 100644 --- a/src/core/plan/planners/playProducts.ts +++ b/src/core/plan/planners/playProducts.ts @@ -14,7 +14,7 @@ type PlayProductsTarget = { products: InAppPurchaseConfig[]; }; /** Resolve the apps that declare at least one Play-overridden in-app product, with their package + products. */ -const targetsFor = (apps: AppDescriptor[], config: LaunchConfig): PlayProductsTarget[] => { +const targetsFor = (apps: readonly AppDescriptor[], config: LaunchConfig): PlayProductsTarget[] => { const targets: PlayProductsTarget[] = []; for (const app of apps) { if (!app.packageName) continue; diff --git a/src/core/plan/planners/playSubscriptions.test.ts b/src/core/plan/planners/playSubscriptions.test.ts index 80805f75..423ca979 100644 --- a/src/core/plan/planners/playSubscriptions.test.ts +++ b/src/core/plan/planners/playSubscriptions.test.ts @@ -39,7 +39,9 @@ const APPLE_ONLY_SUB: SubscriptionConfig = { localizations: [{ locale: 'en-US', name: 'Pro Yearly' }], }; /** Wrap subscriptions in the one group `products[bundleId].subscriptionGroups` requires. */ -const productsWith = (subscriptions: SubscriptionConfig[]): Record => { +const productsWith = ( + subscriptions: readonly SubscriptionConfig[], +): Record => { return { 'com.acme.alpha': { subscriptionGroups: [ diff --git a/src/core/plan/planners/playSubscriptions.ts b/src/core/plan/planners/playSubscriptions.ts index 8a009c22..5d0039f0 100644 --- a/src/core/plan/planners/playSubscriptions.ts +++ b/src/core/plan/planners/playSubscriptions.ts @@ -17,7 +17,10 @@ type PlaySubscriptionsTarget = { subscriptions: SubscriptionConfig[]; }; /** Resolve the apps that declare at least one Play-overridden subscription, with their package + subscriptions. */ -const targetsFor = (apps: AppDescriptor[], config: LaunchConfig): PlaySubscriptionsTarget[] => { +const targetsFor = ( + apps: readonly AppDescriptor[], + config: LaunchConfig, +): PlaySubscriptionsTarget[] => { const targets: PlaySubscriptionsTarget[] = []; for (const app of apps) { if (!app.packageName) continue; diff --git a/src/core/plan/planners/wallet.ts b/src/core/plan/planners/wallet.ts index e50df5da..d777ae0a 100644 --- a/src/core/plan/planners/wallet.ts +++ b/src/core/plan/planners/wallet.ts @@ -2,6 +2,7 @@ import { resolveSidecarConfig } from '@core/config/config.js'; import { loadWalletConfig, reconcileWalletIds } from '@core/store/walletIds.js'; import { planTeamSurface } from './appStoreSurface.js'; import type { SurfacePlanner } from '@core/types/plan.js'; +import type { WalletConfig } from '@core/types/storeSurface.js'; /** Surface id - also the value users pass as `launch plan wallet`. */ const SURFACE = 'wallet'; export const walletPlanner: SurfacePlanner = { @@ -12,7 +13,7 @@ export const walletPlanner: SurfacePlanner = { surface: SURFACE, direction: 'additive', config: () => - resolveSidecarConfig({ + resolveSidecarConfig({ typed: planContext.config.wallet, configPath: 'wallet.config.json', explicitPath: false, diff --git a/src/core/readiness/appScopes.ts b/src/core/readiness/appScopes.ts index 213f3215..6548504b 100644 --- a/src/core/readiness/appScopes.ts +++ b/src/core/readiness/appScopes.ts @@ -4,13 +4,13 @@ export type ScopedApp = { name: string; identifier: string; }; -export const iosApps = (apps: AppDescriptor[]): ScopedApp[] => { +export const iosApps = (apps: readonly AppDescriptor[]): ScopedApp[] => { return apps.flatMap((app) => { if (app.bundleId) return [{ name: app.name, identifier: app.bundleId }]; return []; }); }; -export const androidApps = (apps: AppDescriptor[]): ScopedApp[] => { +export const androidApps = (apps: readonly AppDescriptor[]): ScopedApp[] => { return apps.flatMap((app) => { if (app.packageName) return [{ name: app.name, identifier: app.packageName }]; return []; diff --git a/src/core/readiness/orchestrator.test.ts b/src/core/readiness/orchestrator.test.ts index fcbfb8db..19b89b9f 100644 --- a/src/core/readiness/orchestrator.test.ts +++ b/src/core/readiness/orchestrator.test.ts @@ -42,7 +42,7 @@ const probe = (id: string, fixedProbeResult: ProbeResult | (() => never)): Readi }; /** Run probe aggregation with the platform services available to production probes. */ -const runProbeSet = (readinessProbes: ReadinessProbe[]) => +const runProbeSet = (readinessProbes: readonly ReadinessProbe[]) => Effect.runPromise( runProbes(readinessContext, readinessProbes).pipe( Effect.provide(NodeHttpClient.layer), diff --git a/src/core/readiness/probes/iapCodeReference.ts b/src/core/readiness/probes/iapCodeReference.ts index 55674a47..8fcf2461 100644 --- a/src/core/readiness/probes/iapCodeReference.ts +++ b/src/core/readiness/probes/iapCodeReference.ts @@ -30,7 +30,7 @@ const MAX_SCAN_BYTES = 8 * 1024 * 1024; /** Find declared product identifiers in a bounded app-source scan. */ const findReferencedIds = ( appDirectory: string, - productIds: string[], + productIds: readonly string[], ): Effect.Effect, never, SourceScanRequirements> => { const referencedIds = new Set(); const pendingIds = new Set(productIds); diff --git a/src/core/readiness/probes/profileEntitlements.ts b/src/core/readiness/probes/profileEntitlements.ts index 25000284..13680695 100644 --- a/src/core/readiness/probes/profileEntitlements.ts +++ b/src/core/readiness/probes/profileEntitlements.ts @@ -14,7 +14,7 @@ type EntitledApp = { identifier: string; required: string[]; }; -const entitledApps = (apps: AppDescriptor[]): EntitledApp[] => { +const entitledApps = (apps: readonly AppDescriptor[]): EntitledApp[] => { return apps.flatMap((app) => { if (!app.bundleId) return []; const required = mapEntitlementsToCapabilities(app.iosEntitlements).enable; diff --git a/src/core/snapshot/orchestrator.test.ts b/src/core/snapshot/orchestrator.test.ts index 0a60a8c1..3df2bd13 100644 --- a/src/core/snapshot/orchestrator.test.ts +++ b/src/core/snapshot/orchestrator.test.ts @@ -33,7 +33,7 @@ const source = ( return { id, title: id, store, capture }; }; const META = { name: 'before-sync', capturedAt: '2026-06-16T00:00:00.000Z' }; -const runCapture = (sources: SnapshotSource[]) => +const runCapture = (sources: readonly SnapshotSource[]) => Effect.runPromise(captureSnapshot(makeCtx(), sources, META)); /** A captured surface holding `count` entities under one app. */ const captured = (count: number): SourceCapture => { diff --git a/src/core/snapshot/orchestrator.ts b/src/core/snapshot/orchestrator.ts index 09e20aed..bb6a18d5 100644 --- a/src/core/snapshot/orchestrator.ts +++ b/src/core/snapshot/orchestrator.ts @@ -37,7 +37,7 @@ export const SNAPSHOT_VERSION = 1; */ export const captureSnapshot = ( snapshotContext: SnapshotContext, - sources: SnapshotSource[], + sources: readonly SnapshotSource[], meta: CaptureMeta, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/snapshot/snapshotCommand.ts b/src/core/snapshot/snapshotCommand.ts index a1730611..ce1b58f5 100644 --- a/src/core/snapshot/snapshotCommand.ts +++ b/src/core/snapshot/snapshotCommand.ts @@ -166,9 +166,9 @@ const currentIsoTime = (): Effect.Effect => /** Narrow discovered apps through the snapshot selector in the typed error channel. */ const selectSnapshotApps = ( - discoveredApps: AppDescriptor[], + discoveredApps: readonly AppDescriptor[], appSelector: string | undefined, -): Effect.Effect => +): Effect.Effect => selectApps(discoveredApps, appSelector).pipe( Effect.mapError((cause) => snapshotFailure('select snapshot apps', cause)), ); @@ -176,7 +176,7 @@ const selectSnapshotApps = ( /** Loaded config, selected apps, and store clients for one snapshot command. */ type SnapshotStoreSession = Readonly<{ config: LaunchConfig; - apps: AppDescriptor[]; + apps: readonly AppDescriptor[]; ascClient: EffectAppStoreConnectClient | null; playClient: EffectGooglePlayClient | null; }>; @@ -622,7 +622,7 @@ export const savedEntitiesFor = ( savedSnapshot: Snapshot, sourceId: string, appSelector: string | undefined, -): AppEntities[] => { +): readonly AppEntities[] => { const captureReport = savedSnapshot.reports.find( (snapshotReport) => snapshotReport.id === sourceId, ); diff --git a/src/core/snapshot/sources/appleListing.ts b/src/core/snapshot/sources/appleListing.ts index a0c697a0..29f41cdb 100644 --- a/src/core/snapshot/sources/appleListing.ts +++ b/src/core/snapshot/sources/appleListing.ts @@ -32,7 +32,7 @@ const captureListing = ( ): Effect.Effect => Effect.gen(function* () { const byLocale = new Map>(); - const merge = (localizations: ListingLocalization[]): void => { + const merge = (localizations: readonly ListingLocalization[]): void => { for (const localization of localizations) { byLocale.set(localization.locale, { ...byLocale.get(localization.locale), @@ -48,8 +48,20 @@ const captureListing = ( .sort(([a], [b]) => a.localeCompare(b)) .map(([locale, fields]) => toEntity(locale, fields)); }); +/** Narrow a captured {@link JsonValue} to a plain object (rejecting arrays and null). */ +const isJsonObject = ( + capturedNode: JsonValue, +): capturedNode is Readonly<{ [key: string]: JsonValue }> => { + if (typeof capturedNode !== 'object') return false; + if (capturedNode === null) return false; + if (Array.isArray(capturedNode)) return false; + return true; +}; /** Read a string-valued field from a captured listing's `fields` map, or undefined when absent/non-string. */ -const fieldString = (fields: Record, key: string): string | undefined => { +const fieldString = ( + fields: Readonly<{ [key: string]: JsonValue }>, + key: string, +): string | undefined => { const listingField = fields[key]; if (typeof listingField === 'string') return listingField; return undefined; @@ -59,7 +71,7 @@ const fieldString = (fields: Record, key: string): string | u * `ascSync.routeListing`. Only present fields are carried, and the comma-joined `keywords` string is split * back into the array shape `store.config.json` uses. */ -const toLocaleInfo = (fields: Record): AppleLocaleInfo => { +const toLocaleInfo = (fields: Readonly<{ [key: string]: JsonValue }>): AppleLocaleInfo => { const localeInfo: AppleLocaleInfo = {}; const title = fieldString(fields, 'name'); if (title !== undefined) localeInfo.title = title; @@ -92,15 +104,12 @@ const toListing = (saved: AppEntities): AppleStoreConfig => { const localeInfoByLocale: Record = {}; for (const entity of saved.entities) { const capturedListing = entity.data; - if (typeof capturedListing !== 'object') continue; - if (capturedListing === null) continue; - if (Array.isArray(capturedListing)) continue; + if (!isJsonObject(capturedListing)) continue; const locale = capturedListing['locale']; const fields = capturedListing['fields']; if (typeof locale !== 'string') continue; - if (typeof fields !== 'object') continue; - if (fields === null) continue; - if (Array.isArray(fields)) continue; + if (fields === undefined) continue; + if (!isJsonObject(fields)) continue; localeInfoByLocale[locale] = toLocaleInfo(fields); } return { info: localeInfoByLocale }; @@ -133,7 +142,10 @@ export const appleListingSource: SnapshotSource = { ); return { state: 'captured', - apps: captured.filter((app): app is AppEntities => app !== null), + apps: captured.flatMap((app) => { + if (app === null) return []; + return [app]; + }), }; }); }, diff --git a/src/core/snapshot/sources/appleProducts.ts b/src/core/snapshot/sources/appleProducts.ts index 26fef848..83a378a9 100644 --- a/src/core/snapshot/sources/appleProducts.ts +++ b/src/core/snapshot/sources/appleProducts.ts @@ -1,10 +1,5 @@ import { Effect } from 'effect'; -import type { - AppEntities, - SnapshotContext, - SnapshotEntity, - SnapshotSource, -} from '@core/types/snapshot.js'; +import type { SnapshotContext, SnapshotEntity, SnapshotSource } from '@core/types/snapshot.js'; import { iosApps } from '@core/readiness/appScopes.js'; /** One captured in-app purchase -> a snapshot entity keyed by its product id. */ const toEntity = (iap: { @@ -55,7 +50,10 @@ export const appleProductsSource: SnapshotSource = { ); return { state: 'captured', - apps: captured.filter((app): app is AppEntities => app !== null), + apps: captured.flatMap((app) => { + if (app === null) return []; + return [app]; + }), }; }); }, diff --git a/src/core/snapshot/sources/appleSubscriptions.ts b/src/core/snapshot/sources/appleSubscriptions.ts index 9a841cab..c100d0d9 100644 --- a/src/core/snapshot/sources/appleSubscriptions.ts +++ b/src/core/snapshot/sources/appleSubscriptions.ts @@ -1,10 +1,5 @@ import { Effect } from 'effect'; -import type { - AppEntities, - SnapshotContext, - SnapshotEntity, - SnapshotSource, -} from '@core/types/snapshot.js'; +import type { SnapshotContext, SnapshotEntity, SnapshotSource } from '@core/types/snapshot.js'; import { iosApps } from '@core/readiness/appScopes.js'; /** One captured subscription -> a snapshot entity keyed by its product id. */ const toEntity = ( @@ -66,7 +61,10 @@ export const appleSubscriptionsSource: SnapshotSource = { ); return { state: 'captured', - apps: captured.filter((app): app is AppEntities => app !== null), + apps: captured.flatMap((app) => { + if (app === null) return []; + return [app]; + }), }; }); }, diff --git a/src/core/snapshot/sources/playProducts.ts b/src/core/snapshot/sources/playProducts.ts index 44bda4a3..3a29865e 100644 --- a/src/core/snapshot/sources/playProducts.ts +++ b/src/core/snapshot/sources/playProducts.ts @@ -15,6 +15,7 @@ import type { InAppProductResource, PlayMoney } from '@core/types/googlePlay.js' import type { PlannedAction } from '@core/types/reconcile.js'; import { reconcilePlayProducts } from '@core/store/playProducts.js'; import { androidApps } from '@core/readiness/appScopes.js'; +import type { MutableDeep } from '@core/types/mutable.js'; import { jsonRecord, restoreErrorMessage, @@ -78,7 +79,7 @@ const toLocalizations = ( let name: string | undefined; if (fields) name = stringField(fields, 'title'); if (name === undefined) continue; - const localization: ProductLocalization = { locale, name }; + const localization: MutableDeep = { locale, name }; let description: string | undefined; if (fields) description = stringField(fields, 'description'); if (description !== undefined) localization.description = description; @@ -111,7 +112,7 @@ const toProductConfig = (entity: SnapshotEntity): InAppPurchaseConfig | null => stringField(productFields, 'defaultLanguage'), ); if (localizations.length === 0) return null; - const play: PlayProductOverride = { sku }; + const play: MutableDeep = { sku }; const defaultPrice = toPriceConfig(productFields['defaultPrice']); if (defaultPrice) play.defaultPrice = defaultPrice; return { productId: sku, referenceName: sku, type: 'NON_CONSUMABLE', localizations, play }; diff --git a/src/core/snapshot/sources/playRestore.ts b/src/core/snapshot/sources/playRestore.ts index 742f6641..2ea3c12f 100644 --- a/src/core/snapshot/sources/playRestore.ts +++ b/src/core/snapshot/sources/playRestore.ts @@ -5,16 +5,27 @@ import type { PlannedAction } from '@core/types/reconcile.js'; * Narrow a captured {@link JsonValue} to a plain object (rejecting arrays and null), or `null`, so a * malformed captured section is skipped rather than slipping through as an empty record. */ +const isJsonRecord = ( + capturedNode: JsonValue, +): capturedNode is Readonly<{ [key: string]: JsonValue }> => { + if (typeof capturedNode !== 'object') return false; + if (capturedNode === null) return false; + if (Array.isArray(capturedNode)) return false; + return true; +}; + export const jsonRecord = ( capturedNode: JsonValue | undefined, -): Record | null => { - if (typeof capturedNode !== 'object') return null; - if (capturedNode === null) return null; - if (Array.isArray(capturedNode)) return null; +): Readonly<{ [key: string]: JsonValue }> | null => { + if (capturedNode === undefined) return null; + if (!isJsonRecord(capturedNode)) return null; return capturedNode; }; /** Read a string-valued field from a captured record, or `undefined` when absent/non-string. */ -export const stringField = (record: Record, key: string): string | undefined => { +export const stringField = ( + record: Readonly<{ [key: string]: JsonValue }>, + key: string, +): string | undefined => { const capturedField = record[key]; if (typeof capturedField === 'string') return capturedField; return undefined; diff --git a/src/core/snapshot/sources/playSubscriptions.ts b/src/core/snapshot/sources/playSubscriptions.ts index a12cfae3..567fdd1b 100644 --- a/src/core/snapshot/sources/playSubscriptions.ts +++ b/src/core/snapshot/sources/playSubscriptions.ts @@ -38,7 +38,7 @@ import { * money as `units`+`nanos`, converted to micro-units here so a restore reads it straight back as a * {@link PlayPriceConfig}. Regions Play left price-less are dropped. */ -const regionalPrices = (configs: RegionalBasePlanConfig[]): Record => { +const regionalPrices = (configs: readonly RegionalBasePlanConfig[]): Record => { const prices: Record = {}; for (const config of [...configs].sort((a, b) => a.regionCode.localeCompare(b.regionCode))) { if (config.price) { @@ -51,7 +51,7 @@ const regionalPrices = (configs: RegionalBasePlanConfig[]): Record { +const basePlans = (plans: readonly BasePlan[]): JsonValue => { return plans.map((plan): JsonValue => { let prices: Record = {}; if (plan.regionalConfigs) prices = regionalPrices(plan.regionalConfigs); @@ -64,7 +64,7 @@ const basePlans = (plans: BasePlan[]): JsonValue => { }); }; /** A subscription's listings, normalized to language + title pairs. */ -const listings = (items: SubscriptionListing[]): JsonValue => { +const listings = (items: readonly SubscriptionListing[]): JsonValue => { return items.map( (listing): JsonValue => ({ languageCode: listing.languageCode, title: listing.title }), ); diff --git a/src/core/snapshot/store.ts b/src/core/snapshot/store.ts index e2ec6e26..813e44a9 100644 --- a/src/core/snapshot/store.ts +++ b/src/core/snapshot/store.ts @@ -21,68 +21,54 @@ const JsonValueSchema: Schema.Schema = Schema.suspend(() => Schema.Number, Schema.Boolean, Schema.Null, - Schema.mutable(Schema.Array(JsonValueSchema)), - Schema.mutable(Schema.Record({ key: Schema.String, value: JsonValueSchema })), + Schema.Array(JsonValueSchema), + Schema.Record({ key: Schema.String, value: JsonValueSchema }), ), ); -const SnapshotEntitySchema: Schema.Schema = Schema.mutable( - Schema.Struct({ - key: Schema.String, - summary: Schema.String, - data: JsonValueSchema, - }), -); +const SnapshotEntitySchema: Schema.Schema = Schema.Struct({ + key: Schema.String, + summary: Schema.String, + data: JsonValueSchema, +}); -const AppEntitiesSchema: Schema.Schema = Schema.mutable( - Schema.Struct({ - app: Schema.String, - identifier: Schema.String, - entities: Schema.mutable(Schema.Array(SnapshotEntitySchema)), - }), -); +const AppEntitiesSchema: Schema.Schema = Schema.Struct({ + app: Schema.String, + identifier: Schema.String, + entities: Schema.Array(SnapshotEntitySchema), +}); const CaptureOutcomeSchema: Schema.Schema = Schema.Union( - Schema.mutable(Schema.Struct({ state: Schema.Literal('omitted') })), - Schema.mutable( - Schema.Struct({ - state: Schema.Literal('skipped'), - reason: Schema.String, - hint: Schema.optionalWith(Schema.String, { exact: true }), - }), - ), - Schema.mutable( - Schema.Struct({ - state: Schema.Literal('captured'), - apps: Schema.mutable(Schema.Array(AppEntitiesSchema)), - }), - ), - Schema.mutable( - Schema.Struct({ - state: Schema.Literal('errored'), - error: Schema.String, - }), - ), -); - -const CaptureReportSchema: Schema.Schema = Schema.mutable( + Schema.Struct({ state: Schema.Literal('omitted') }), Schema.Struct({ - id: Schema.String, - title: Schema.String, - store: Schema.Literal('appstore', 'play'), - outcome: CaptureOutcomeSchema, + state: Schema.Literal('skipped'), + reason: Schema.String, + hint: Schema.optionalWith(Schema.String, { exact: true }), + }), + Schema.Struct({ + state: Schema.Literal('captured'), + apps: Schema.Array(AppEntitiesSchema), }), -); - -const SnapshotSchema: Schema.Schema = Schema.mutable( Schema.Struct({ - version: Schema.Number, - name: Schema.String, - capturedAt: Schema.String, - reports: Schema.mutable(Schema.Array(CaptureReportSchema)), + state: Schema.Literal('errored'), + error: Schema.String, }), ); +const CaptureReportSchema: Schema.Schema = Schema.Struct({ + id: Schema.String, + title: Schema.String, + store: Schema.Literal('appstore', 'play'), + outcome: CaptureOutcomeSchema, +}); + +const SnapshotSchema: Schema.Schema = Schema.Struct({ + version: Schema.Number, + name: Schema.String, + capturedAt: Schema.String, + reports: Schema.Array(CaptureReportSchema), +}); + type SnapshotStoreRequirements = FileSystem.FileSystem | LaunchPathsService | Path.Path; /** Resolve the persisted snapshot directory or an explicit test/CLI override. */